303 lines
14 KiB
Python
303 lines
14 KiB
Python
"""Backend contract checks with local HTTP fixtures and a loopback voice server.
|
|
|
|
Run from the repository root: 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()
|