"""Backend contract checks with local HTTP fixtures and a loopback voice server. Run from vocero-api: uv run --locked pytest tests """ import json import os import threading import unittest from unittest.mock import AsyncMock, Mock, patch import httpx 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 vocero_api import main as server from vocero_api.core import config as settings from vocero_api.providers import caller 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_apipilot_routes_and_contracts_are_retired(self): self.assertNotIn("apipilot", self.client.get("/api/config").json()) spec = self.client.get("/openapi.json").json() for method, path in (("GET", "/api/apipilot/tools"), ("POST", "/api/apipilot/execute")): self.assertNotIn(path, spec["paths"]) response = self.client.request(method, path, json={"name": "get_report"}) self.assertEqual(response.status_code, 404) schemas = spec["components"]["schemas"] for name in ("ApipilotExecuteRequest", "ToolCatalogResponse", "ToolExecutionResponse", "ToolInvocation"): self.assertNotIn(name, schemas) self.assertNotIn("apipilot", schemas["ConfigResponse"]["properties"]) self.assertEqual(set(schemas["ReasoningResponse"]["properties"]), {"answer"}) self.upstream.assert_not_called() class VoiceProxyTests(unittest.TestCase): def test_caller_and_episode_context_reach_the_voice_session(self): closed = threading.Event() login = AsyncMock(return_value=("fixture-token", {})) profile = AsyncMock(return_value={"user": {"name": "Local Caller", "email": "caller@example.test"}}) episode = AsyncMock(return_value={"title": "Local episode", "text": "Local episode text"}) 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", True), 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(caller, "_login_by_phone", login), patch.object(caller, "_fetch_user_profile_for_phone", profile), patch.object(caller, "_fetch_podcast_episode", episode), 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", "Local Caller", "Local episode text", "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") episode.assert_awaited_once_with("fixture-episode") 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()