refactor(api): scope uv to s2s-client and validate compatible responses

This commit is contained in:
2026-10-06 18:23:08 -03:00
parent 92c53dc7ca
commit f8d5b7857a
39 changed files with 316 additions and 67 deletions
+302
View File
@@ -0,0 +1,302 @@
"""Backend contract checks with local HTTP fixtures and a loopback voice server.
Run from s2s-client: uv run --locked pytest tests
"""
import json
import os
import threading
import unittest
from unittest.mock import ANY, AsyncMock, Mock, patch
import httpx
from fastapi import Request
from fastapi.testclient import TestClient
from websockets.sync.server import serve
# Never consume deployment credentials or contact services from a developer's .env.
with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_value=False):
from s2s_client import main as server
from s2s_client.core import config as settings
from s2s_client.providers import apipilot, caller
from s2s_client.services import tool_context
GRANT = {
"session_id": "local-session",
"connect_url": "ws://voice.test/v1/realtime?token=fixture",
"pending_timeout_s": 60,
}
TICKET = {"state": "queued", "queue_id": "local-ticket", "position": 2, "poll_interval_s": 1}
ASYNC_CLIENT = httpx.AsyncClient
class BackendContractTests(unittest.TestCase):
def setUp(self):
self.upstream = Mock(side_effect=AssertionError("Unexpected upstream HTTP request"))
self.enterContext(patch.object(settings, "LOAD_BALANCER_URL", "http://lb.test"))
self.enterContext(
patch.object(
httpx,
"AsyncClient",
side_effect=lambda **kwargs: ASYNC_CLIENT(
transport=httpx.MockTransport(self.upstream),
**kwargs,
),
)
)
self.client = self.enterContext(TestClient(server.app))
def respond(self, *responses):
self.upstream.side_effect = iter(responses)
def test_bootstrap_and_removed_account_contract(self):
response = self.client.get("/api/config")
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["lb"])
self.assertFalse(response.json()["allowDirect"])
self.assertNotIn("auth", response.json())
paths = self.client.get("/openapi.json").json()["paths"]
for method, path in (
("GET", "/api/me"),
("POST", "/api/session/heartbeat"),
("POST", "/api/session/end"),
):
self.assertNotIn(path, paths)
self.assertEqual(self.client.request(method, path).status_code, 404)
self.assertFalse(any(getattr(route, "path", "").startswith("/oauth") for route in server.app.routes))
self.upstream.assert_not_called()
def test_only_documentation_is_served_as_static_content(self):
for path in ("/", "/index.html", "/main.js", "/style.css", "/server.py", "/requirements.txt"):
self.assertEqual(self.client.get(path).status_code, 404)
self.assertEqual(self.client.get("/docs").status_code, 200)
self.assertEqual(self.client.get("/docs/README.md").status_code, 200)
for path in ("/project/", "/public/docs.html"):
response = self.client.get(path, follow_redirects=False)
self.assertEqual(response.status_code, 307)
self.assertEqual(response.headers["location"], "/docs/proposals/vocero-architecture.html")
self.upstream.assert_not_called()
def test_session_grant_passes_through_without_extra_fields_or_cookies(self):
self.respond(httpx.Response(200, json=GRANT))
response = self.client.post("/api/session", json={})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), GRANT)
self.assertNotIn("set-cookie", response.headers)
request = self.upstream.call_args.args[0]
self.assertEqual((request.method, str(request.url)), ("POST", "http://lb.test/session"))
self.assertEqual(json.loads(request.content), {})
def test_queue_polling_returns_positions_and_then_grant(self):
self.respond(
httpx.Response(200, json=TICKET),
httpx.Response(200, json={**TICKET, "position": 1}),
httpx.Response(200, json=GRANT),
)
self.assertEqual(self.client.post("/api/session").json(), TICKET)
self.assertEqual(self.client.get("/api/queue/local-ticket").json(), {**TICKET, "position": 1})
response = self.client.get("/api/queue/local-ticket")
self.assertEqual(response.json(), GRANT)
self.assertNotIn("set-cookie", response.headers)
self.assertEqual(
[(call.args[0].method, call.args[0].url.path) for call in self.upstream.call_args_list],
[("POST", "/session"), ("GET", "/queue/local-ticket"), ("GET", "/queue/local-ticket")],
)
def test_capacity_and_expired_queue_responses_remain(self):
self.respond(httpx.Response(503, json={"state": "at_capacity"}), httpx.Response(404))
response = self.client.post("/api/session")
self.assertEqual((response.status_code, response.json()), (503, {"state": "at_capacity"}))
response = self.client.get("/api/queue/local-ticket")
self.assertEqual((response.status_code, response.json()), (404, {"state": "expired"}))
def test_explicit_and_beacon_queue_cancellation(self):
self.respond(httpx.Response(204), httpx.Response(204))
self.assertEqual(self.client.delete("/api/queue/local-ticket").json(), {"ok": True})
# sendBeacon posts a JSON blob, not a form or a session identifier.
response = self.client.post(
"/api/queue/end",
content=json.dumps({"queueId": "local-ticket"}),
headers={"Content-Type": "application/json"},
)
self.assertEqual(response.json(), {"ok": True})
for call in self.upstream.call_args_list:
self.assertEqual((call.args[0].method, call.args[0].url.path), ("DELETE", "/queue/local-ticket"))
self.assertEqual(self.upstream.call_count, 2)
def test_empty_beacon_is_harmless_and_cancellation_is_best_effort(self):
self.assertEqual(self.client.post("/api/queue/end", content="").json(), {"ok": True})
self.upstream.assert_not_called()
self.upstream.side_effect = httpx.ConnectError("Local fixture unavailable")
self.assertEqual(self.client.delete("/api/queue/local-ticket").json(), {"ok": True})
def test_upstream_failures_are_still_reported(self):
self.upstream.side_effect = httpx.ConnectError("Local fixture unavailable")
self.assertEqual(self.client.post("/api/session").status_code, 502)
self.assertEqual(self.client.get("/api/queue/local-ticket").status_code, 502)
self.respond(httpx.Response(429), httpx.Response(500))
self.assertEqual(self.client.post("/api/session").status_code, 502)
self.assertEqual(self.client.get("/api/queue/local-ticket").status_code, 502)
def test_direct_mode_disables_lb_and_preserves_pinned_voice_url(self):
with (
patch.object(settings, "LOAD_BALANCER_URL", ""),
patch.object(
settings,
"SPEECH_TO_SPEECH_URL",
GRANT["connect_url"],
),
):
config = self.client.get("/api/config").json()
self.assertEqual(config["s2sUrl"], GRANT["connect_url"])
self.assertFalse(config["lb"])
self.assertTrue(config["allowDirect"])
for method, path in (
("POST", "/api/session"),
("GET", "/api/queue/local-ticket"),
("DELETE", "/api/queue/local-ticket"),
("POST", "/api/queue/end"),
):
self.assertEqual(self.client.request(method, path).status_code, 404)
self.upstream.assert_not_called()
def test_caller_profile_still_uses_upstream_login_token(self):
profile = {"result": {"name": "Local Caller"}}
self.respond(
httpx.Response(200, json={"token": "fixture-token"}),
httpx.Response(200, json=profile),
)
with (
patch.object(settings, "S2S_LOGIN_BY_PHONE", "http://profile.test/login"),
patch.object(
settings,
"VOCERO_USER_PHONE_AUTH_URL",
"http://profile.test/user",
),
):
response = self.client.get("/api/user/phone", params={"phoneNumber": "+56000000000"})
self.assertEqual(response.json(), profile)
login, lookup = [call.args[0] for call in self.upstream.call_args_list]
self.assertEqual(json.loads(login.content), {"phoneNumber": "+56000000000"})
self.assertEqual(lookup.headers["authorization"], "Bearer fixture-token")
self.assertEqual(lookup.url.params["phoneNumber"], "+56000000000")
def test_caller_auth_headers_remain_independent(self):
request = Request(
{
"type": "http",
"headers": [
(b"authorization", b"Bearer fixture-token"),
(b"x-demo-session", b"fixture-demo"),
],
}
)
self.assertEqual(apipilot._apipilot_parse_authorization(request), ("Bearer fixture-token", "fixture-demo"))
class VoiceProxyTests(unittest.TestCase):
def test_apipilot_catalog_and_caller_defaults_reach_the_voice_session(self):
closed = threading.Event()
catalog = AsyncMock(
return_value=[
{
"name": "get_caller_briefing",
"description": "Retrieve the caller's local test briefing.",
"requires_auth": True,
"parameters": {"required": ["phone", "episode_id", "email"]},
}
]
)
login = AsyncMock(return_value=("fixture-token", {}))
profile = AsyncMock(return_value={"user": {"email": "caller@example.test"}})
def voice(socket):
try:
socket.send(json.dumps({"type": "session.created"}))
for frame in socket:
socket.send(frame)
finally:
closed.set()
with serve(voice, "127.0.0.1", 0) as upstream:
thread = threading.Thread(target=upstream.serve_forever, daemon=True)
thread.start()
self.addCleanup(thread.join, 5)
target = f"ws://127.0.0.1:{upstream.socket.getsockname()[1]}/v1/realtime"
with (
patch.object(settings, "S2S_PROXY_TARGET", target),
patch.object(settings, "S2S_INJECT_PROFILE", False),
patch.object(settings, "S2S_APIPILOT_TOOLS_URL", "http://catalog.test/tools"),
patch.object(settings, "S2S_LOGIN_BY_PHONE", "http://profile.test/login"),
patch.object(settings, "VOCERO_USER_PHONE_AUTH_URL", "http://profile.test/user"),
patch.object(apipilot, "_apipilot_fetch_tools", catalog),
patch.object(caller, "_login_by_phone", login),
patch.object(caller, "_fetch_user_profile_for_phone", profile),
patch.object(httpx, "AsyncClient", side_effect=AssertionError("Unexpected HTTP request")),
TestClient(server.app) as client,
):
with client.websocket_connect(
"/v1/realtime?phoneNumber=%2B56000000000&episode_id=fixture-episode",
) as socket:
self.assertEqual(socket.receive_json(), {"type": "session.created"})
socket.send_json({"type": "session.update", "session": {"instructions": "Local fixture"}})
update = socket.receive_json()
self.assertEqual(update["type"], "session.update")
instructions = update["session"]["instructions"]
for expected in (
"Local fixture",
tool_context.APIPILOT_PASS1_LIST_MARKER,
"name: get_caller_briefing",
'phone = "+56000000000"',
'episode_id = "fixture-episode"',
'email = "caller@example.test"',
):
self.assertIn(expected, instructions)
# Configuration injection must preserve ordinary audio forwarding.
audio = {"type": "input_audio_buffer.append", "audio": "AAA="}
socket.send_json(audio)
self.assertEqual(socket.receive_json(), audio)
self.assertTrue(closed.wait(3), "Client disconnect must close the upstream WebSocket")
login.assert_awaited_once_with("+56000000000")
profile.assert_awaited_once_with("+56000000000", auth_token="fixture-token")
catalog.assert_awaited_once_with(auth_header="Bearer fixture-token", demo_session=ANY, refresh=False)
def test_proxy_voice_frames_keepalive_and_client_disconnect(self):
closed = threading.Event()
received = []
keepalive = threading.Event()
def voice(socket):
try:
socket.send(json.dumps({"type": "session.created"}))
if socket.ping().wait(3):
keepalive.set()
for frame in socket:
received.append(frame)
socket.send(frame)
finally:
closed.set()
with serve(voice, "127.0.0.1", 0) as upstream:
thread = threading.Thread(target=upstream.serve_forever, daemon=True)
thread.start()
self.addCleanup(thread.join, 5)
target = f"ws://127.0.0.1:{upstream.socket.getsockname()[1]}/v1/realtime"
with patch.object(settings, "S2S_PROXY_TARGET", target), TestClient(server.app) as client:
config = client.get("/api/config", headers={"host": "localhost:19420"}).json()
self.assertEqual(config["s2sUrl"], "ws://localhost:19420/v1/realtime")
with client.websocket_connect("/v1/realtime") as socket:
self.assertEqual(socket.receive_json(), {"type": "session.created"})
update = {"type": "session.update", "session": {"instructions": "Local fixture"}}
socket.send_json(update)
self.assertEqual(socket.receive_json(), update)
socket.send_bytes(b"\x00\x01\x02\x03")
self.assertEqual(socket.receive_bytes(), b"\x00\x01\x02\x03")
self.assertTrue(keepalive.wait(3))
self.assertTrue(closed.wait(3), "Client disconnect must close the upstream WebSocket")
self.assertEqual(len(received), 2)
if __name__ == "__main__":
unittest.main()