refactor(server): adopt uv and modular typed FastAPI structure
This commit is contained in:
-1422
File diff suppressed because it is too large
Load Diff
@@ -1,275 +0,0 @@
|
||||
"""Golden wire behavior captured before the tooling/structure migration.
|
||||
|
||||
Fixtures include permissive payloads and existing failures deliberately. Changing
|
||||
them requires a separate, explicitly approved API behavior change.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_value=False):
|
||||
server = importlib.import_module("server")
|
||||
|
||||
ASYNC_CLIENT = httpx.AsyncClient
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "legacy-contracts.json"
|
||||
BASE_CONFIG = {
|
||||
"LOAD_BALANCER_URL": "",
|
||||
"SPEECH_TO_SPEECH_URL": "",
|
||||
"S2S_PROXY_TARGET": "",
|
||||
"S2S_PRIMARY_PROMPT": "",
|
||||
"SERPER_KEY": "",
|
||||
"S2S_REASONING_TYPE": "PROXY",
|
||||
"S2S_INJECT_PROFILE": False,
|
||||
"S2S_INJECT_REPORTS": False,
|
||||
"S2S_DEBUG_PROMPT": False,
|
||||
"S2S_START_SESSION_MODE": "",
|
||||
"S2S_THEME": "",
|
||||
"VOCERO_USER_PHONE_AUTH_URL": "",
|
||||
"S2S_REPORT_LIST": "",
|
||||
"S2S_LOGIN_BY_PHONE": "",
|
||||
"S2S_LATEST_SUMMARY": "",
|
||||
"S2S_LATEST_SUMMARY_TEXT": "",
|
||||
"S2S_APIPILOT_TOOLS_URL": "",
|
||||
"S2S_APIPILOT_TOOLS_EXECUTE_URL": "",
|
||||
"S2S_APIPILOT_SERVICE_TOKEN": "",
|
||||
"S2S_APIPILOT_API_KEY": "",
|
||||
"OPENAI_API_KEY": "",
|
||||
"NODE_RED_REASONING_URL": "",
|
||||
"S2S_REPORTS_QUICK_HINT_MAX": 5,
|
||||
"S2S_REPORTS_INJECT_MODE": "minimal",
|
||||
}
|
||||
TOOL = {
|
||||
"name": "fixture_tool",
|
||||
"description": "A fixture tool.",
|
||||
"parameters": {"type": "object", "properties": {"count": {"type": "integer"}}, "required": ["count"]},
|
||||
"requires_auth": False,
|
||||
"future_definition_field": {"nested": True},
|
||||
}
|
||||
|
||||
|
||||
def cases():
|
||||
"""Each case controls all upstream IO; no deployment services are contacted."""
|
||||
yield "config-default", "GET", "/api/config", {}, {}, []
|
||||
yield "config-proxy", "GET", "/api/config", {
|
||||
"headers": {"x-forwarded-proto": "https", "x-forwarded-host": "voice.test, internal.test"},
|
||||
}, {"S2S_PROXY_TARGET": "ws://private.test/voice"}, []
|
||||
yield "config-direct", "GET", "/api/config", {}, {"SPEECH_TO_SPEECH_URL": "wss://voice.test/realtime?token=fixture"}, []
|
||||
yield "config-lb", "GET", "/api/config", {}, {"LOAD_BALANCER_URL": "http://lb.test"}, []
|
||||
for path in ("/api/me", "/api/session/heartbeat", "/api/session/end", "/", "/server.py"):
|
||||
yield f"removed-{path}", "GET", path, {}, {}, []
|
||||
for path in ("/project", "/project/", "/project/index.html", "/public/docs.html"):
|
||||
yield f"redirect-{path}", "GET", path, {}, {}, []
|
||||
for path in ("/api/session", "/api/queue/end"):
|
||||
yield f"disabled-{path}", "POST", path, {}, {}, []
|
||||
for method in ("GET", "DELETE"):
|
||||
yield f"disabled-queue-{method}", method, "/api/queue/ticket", {}, {}, []
|
||||
|
||||
for name, payload in (
|
||||
("missing", {}), ("blank", {"query": " "}), ("wrong-type", {"query": 17}),
|
||||
("null", {"query": None}), ("unconfigured", {"query": "hello"}),
|
||||
):
|
||||
yield f"search-{name}", "POST", "/api/search", {"json": payload}, {}, []
|
||||
search_config = {"SERPER_KEY": "server-fixture-key"}
|
||||
yield "search-success-extra-fields", "POST", "/api/search", {"json": {
|
||||
"query": " hello ", "key": "caller-fixture-key", "unknown_request_field": [1, 2],
|
||||
}}, search_config, [(200, {"answerBox": {"answer": "Answer"}, "organic": [
|
||||
{"title": "Title", "snippet": "Text", "link": "https://example.test", "unknown": 1},
|
||||
], "future_upstream": {"nested": True}})]
|
||||
yield "search-loose-values", "POST", "/api/search", {"json": {"query": "hello"}}, search_config, [(200, {
|
||||
"answerBox": {"answer": {"future": [1, None]}},
|
||||
"organic": [{"title": None, "snippet": 3, "link": {"href": "unusual"}}, {}],
|
||||
})]
|
||||
yield "search-upstream-error", "POST", "/api/search", {"json": {"query": "hello"}}, search_config, [(429, {"message": "Fixture credits exhausted"})]
|
||||
yield "search-invalid-upstream-json", "POST", "/api/search", {"json": {"query": "hello"}}, search_config, [(200, b"not-json")]
|
||||
|
||||
yield "profile-missing-phone", "GET", "/api/user/phone", {}, {}, []
|
||||
yield "profile-unconfigured", "GET", "/api/user/phone?phoneNumber=123", {}, {}, []
|
||||
profile_config = {"VOCERO_USER_PHONE_AUTH_URL": "http://profile.test/user"}
|
||||
yield "profile-blank-phone", "GET", "/api/user/phone?phoneNumber=%20", {}, profile_config, []
|
||||
for name, status, payload in (
|
||||
("object", 200, {"result": {"name": "Caller", "unknown": {"nested": [1, None]}}}),
|
||||
("array", 200, [1, {"future": "field"}]), ("null", 200, None),
|
||||
("string", 200, "opaque profile"), ("error", 403, {"future_error": True}),
|
||||
("invalid-json", 200, b"not-json"),
|
||||
):
|
||||
yield f"profile-{name}", "GET", "/api/user/phone?phoneNumber=%2B56000000000", {}, profile_config, [(status, payload)]
|
||||
yield "profile-login-token", "GET", "/api/user/phone?phoneNumber=123", {}, {
|
||||
**profile_config, "S2S_LOGIN_BY_PHONE": "http://profile.test/login",
|
||||
}, [(200, {"result": {"data": {"accessToken": "fixture-token", "future": True}}}), (200, {"name": "Caller"})]
|
||||
|
||||
yield "reports-disabled", "GET", "/api/user/reports", {}, {}, []
|
||||
yield "reports-unconfigured", "GET", "/api/user/reports", {}, {"S2S_INJECT_REPORTS": True}, []
|
||||
reports_config = {"S2S_INJECT_REPORTS": True, "S2S_REPORT_LIST": "http://reports.test/list"}
|
||||
yield "reports-empty", "GET", "/api/user/reports", {}, reports_config, [(200, [])]
|
||||
yield "reports-upstream-failure", "GET", "/api/user/reports", {}, reports_config, [(503, {"error": "fixture"})]
|
||||
yield "reports-normalization", "GET", "/api/user/reports?phoneNumber=123", {}, reports_config, [(200, {"result": [
|
||||
{"sourceScheduleLabel": "Daily", "date": "2026-01-02T12:00:00Z", "podcastScript": "private script", "future": True},
|
||||
{"sourceScheduleLabel": "Daily", "date": "2026-01-01T12:00:00Z", "podcastScript": "older script"},
|
||||
{"sourceSchedule": {"name": "Weekly", "date": "2026-01-03T12:00:00Z"}, "text": "weekly script"},
|
||||
], "future": True})]
|
||||
|
||||
lb_config = {"LOAD_BALANCER_URL": "http://lb.test"}
|
||||
for name, status, payload in (
|
||||
("grant", 200, {"session_id": "session", "connect_url": "ws://voice.test?token=fixture", "future": {"nested": True}}),
|
||||
("camelcase", 200, {"sessionId": "session", "connectUrl": "ws://voice.test", "unknown": None}),
|
||||
("queued", 200, {"state": "queued", "queue_id": "ticket", "position": 3, "future": [1]}),
|
||||
("empty", 200, {}), ("capacity", 503, {"state": "at_capacity", "future": True}),
|
||||
("other-error", 429, {"error": "fixture"}), ("invalid-json", 200, b"not-json"),
|
||||
):
|
||||
yield f"session-{name}", "POST", "/api/session", {"content": b"arbitrary ignored request body"}, lb_config, [(status, payload)]
|
||||
for name, status, payload in (
|
||||
("waiting", 200, {"state": "queued", "queue_id": "ticket", "position": None, "future": True}),
|
||||
("grant", 200, {"session_id": "session", "connect_url": "ws://voice.test"}),
|
||||
("expired", 404, {"unknown": True}), ("error", 500, {}), ("invalid-json", 200, b"bad"),
|
||||
):
|
||||
yield f"queue-{name}", "GET", "/api/queue/ticket", {}, lb_config, [(status, payload)]
|
||||
yield "queue-delete", "DELETE", "/api/queue/ticket", {}, lb_config, [(500, {})]
|
||||
for name, kwargs, upstream in (
|
||||
("valid", {"json": {"queueId": "ticket", "future": True}}, [(204, b"")]),
|
||||
("empty", {"content": b""}, []), ("invalid-json", {"content": b"bad"}, []),
|
||||
("array", {"json": ["ticket"]}, []), ("null-id", {"json": {"queueId": None}}, []),
|
||||
("numeric-id", {"json": {"queueId": 42}}, []),
|
||||
):
|
||||
yield f"queue-end-{name}", "POST", "/api/queue/end", kwargs, lb_config, upstream
|
||||
|
||||
for name, payload in (("missing", {}), ("proxy", {"transcript": "hello"}), ("proxy-blank", {"transcript": " "})):
|
||||
yield f"reasoning-{name}", "POST", "/api/reasoning", {"json": payload}, {}, []
|
||||
for mode in ("OPENAI", "NODE_RED"):
|
||||
yield f"reasoning-{mode}-legacy-unbound-token", "POST", "/api/reasoning", {"json": {"transcript": "hello"}}, {"S2S_REASONING_TYPE": mode}, []
|
||||
yield "reasoning-legacy-unbound-profile", "POST", "/api/reasoning", {"json": {"transcript": "hello"}}, {
|
||||
"S2S_REASONING_TYPE": "OPENAI", "S2S_INJECT_PROFILE": True, "S2S_LATEST_SUMMARY_TEXT": "fixture summary",
|
||||
}, []
|
||||
reasoning_config = {**profile_config, "S2S_INJECT_PROFILE": True, "S2S_REASONING_TYPE": "NODE_RED", "NODE_RED_REASONING_URL": "http://reasoning.test"}
|
||||
reasoning_request = {"json": {"transcript": " hello ", "phoneNumber": "123", "instructions": "Fixture instructions", "language": "es", "future": True}}
|
||||
for name, payload in (
|
||||
("answer", {"answer": " answer ", "latency_ms": "loose", "intent": {"future": 1}, "usage": {"future": True}, "future": True}),
|
||||
("log-only", {"answer": "", "log_only": True, "debug": {"future": True}}),
|
||||
("invalid-answer", {"answer": 5}),
|
||||
):
|
||||
yield f"reasoning-node-{name}", "POST", "/api/reasoning", reasoning_request, reasoning_config, [(200, {"name": "Caller"}), (200, payload)]
|
||||
yield "reasoning-openai-answer", "POST", "/api/reasoning", reasoning_request, {
|
||||
**reasoning_config, "S2S_REASONING_TYPE": "OPENAI", "OPENAI_API_KEY": "fixture-key",
|
||||
}, [(200, {"name": "Caller"}), (200, {"choices": [{"message": {"content": " answer ", "future": True}}], "usage": {"total_tokens": 12}, "future": True})]
|
||||
|
||||
for method, path, kwargs in (("GET", "/api/apipilot/tools", {}), ("POST", "/api/apipilot/execute", {"json": {"name": "fixture_tool"}})):
|
||||
yield f"apipilot-disabled-{method}", method, path, kwargs, {}, []
|
||||
tool_config = {"S2S_APIPILOT_TOOLS_URL": "http://tools.test/apipilot"}
|
||||
yield "tools-query-validation", "GET", "/api/apipilot/tools?refresh=invalid", {}, tool_config, []
|
||||
for name, payload in (("list", [TOOL]), ("result", {"result": [TOOL]}), ("envelope", {"data": {"tools": [TOOL]}, "future": True}), ("opaque", "anything")):
|
||||
yield f"tools-{name}", "GET", "/api/apipilot/tools?include_unauthorized=false&refresh=true", {"headers": {"Authorization": "Bearer fixture-token", "x-demo-session": "fixture-session"}}, tool_config, [(200, payload)]
|
||||
yield "tools-upstream-error", "GET", "/api/apipilot/tools", {}, tool_config, [(403, {"error": "fixture"})]
|
||||
yield "execute-discovery-error-envelope", "POST", "/api/apipilot/execute", {"json": {"name": "fixture_tool"}}, tool_config, [(503, {})]
|
||||
yield "execute-unknown-tool", "POST", "/api/apipilot/execute", {"json": {"name": "unknown"}}, tool_config, [(200, [TOOL]), (200, [TOOL])]
|
||||
yield "execute-missing-argument", "POST", "/api/apipilot/execute", {"json": {"name": "fixture_tool"}}, tool_config, [(200, [TOOL])]
|
||||
yield "execute-api-key-precheck", "POST", "/api/apipilot/execute", {"json": {"name": "fixture_tool", "arguments": {"count": 1}}}, {
|
||||
**tool_config, "S2S_APIPILOT_API_KEY": "fixture-key",
|
||||
}, [(200, [{**TOOL, "requires_auth": True}])]
|
||||
for name, payload in (
|
||||
("envelope", {"success": True, "data": {"future": [1, None]}, "error": None, "future_envelope": True}),
|
||||
("raw-list", [{"future": True}]), ("minimal-envelope", {"success": True}),
|
||||
("error", {"success": False, "data": None, "error": {"status_code": 409, "code": "FIXTURE", "message": "Fixture", "future": True}}),
|
||||
):
|
||||
yield f"execute-{name}", "POST", "/api/apipilot/execute", {"json": {"name": "fixture_tool", "arguments": {"count": "wrong-type-is-accepted", "unknown": 1}, "future": True}}, tool_config, [(200, [TOOL]), (200, payload)]
|
||||
|
||||
|
||||
def capture_http():
|
||||
captures = {}
|
||||
for name, method, path, kwargs, config, responses in cases():
|
||||
remaining = iter(responses)
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append({
|
||||
"method": request.method,
|
||||
"url": str(request.url),
|
||||
"body": request.content.decode(),
|
||||
"headers": {key: request.headers[key] for key in (
|
||||
"authorization", "x-demo-session", "x-api-key", "content-type", "x-s2s-caller",
|
||||
) if key in request.headers},
|
||||
})
|
||||
status, payload = next(remaining)
|
||||
if isinstance(payload, bytes):
|
||||
return httpx.Response(status, content=payload)
|
||||
return httpx.Response(status, content=json.dumps(payload).encode(), headers={"content-type": "application/json"})
|
||||
|
||||
with (
|
||||
patch.multiple(server, **{**BASE_CONFIG, **config}),
|
||||
patch.object(server, "_APIPILOT_CACHE", {}),
|
||||
patch.object(server, "_build_today_fixed_context_block", return_value="Fixture current date: 2026-01-04"),
|
||||
patch.object(server.httpx, "AsyncClient", side_effect=lambda **opts: ASYNC_CLIENT(transport=httpx.MockTransport(upstream), **opts)),
|
||||
TestClient(server.app, raise_server_exceptions=False) as client,
|
||||
):
|
||||
response = client.request(method, path, follow_redirects=False, **kwargs)
|
||||
captures[name] = {
|
||||
"status": response.status_code,
|
||||
"body": response.text,
|
||||
"headers": {key: response.headers[key] for key in ("content-type", "location", "set-cookie") if key in response.headers},
|
||||
"upstream": requests,
|
||||
}
|
||||
if list(remaining):
|
||||
raise AssertionError(f"Unused upstream fixtures in {name}")
|
||||
return captures
|
||||
|
||||
|
||||
def capture_openapi_requests():
|
||||
schema = server.app.openapi()
|
||||
return {
|
||||
"operations": {
|
||||
f"{method.upper()} {path}": {key: operation[key] for key in ("operationId", "parameters", "requestBody") if key in operation}
|
||||
for path, methods in schema["paths"].items()
|
||||
for method, operation in methods.items()
|
||||
},
|
||||
"models": {name: schema["components"]["schemas"][name] for name in (
|
||||
"SearchRequest", "ReasoningRequest", "ApipilotExecuteRequest", "HTTPValidationError", "ValidationError",
|
||||
)},
|
||||
}
|
||||
|
||||
|
||||
async def capture_realtime():
|
||||
frames = []
|
||||
|
||||
class Socket:
|
||||
async def send_text(self, message):
|
||||
frames.append(message)
|
||||
|
||||
async def send_bytes(self, message):
|
||||
frames.append({"binary": message.hex()})
|
||||
|
||||
async def upstream():
|
||||
yield b"\x00\x01\x02\x03"
|
||||
yield json.dumps({"type": "future.event", "future": {"nested": [None, 1]}})
|
||||
yield json.dumps({"type": "response.text.done", "text": "Hello [rightspeak]world[endspeak]", "future": True})
|
||||
yield json.dumps({"type": "error", "error": {"message": "Unknown or invalid event: session.update"}})
|
||||
yield json.dumps({"type": "future.after.suppressed.error"})
|
||||
|
||||
with patch.object(server, "_forward_agent_text_to_nodered", new_callable=AsyncMock):
|
||||
await server._upstream_to_client(Socket(), upstream(), "fixture", {})
|
||||
return frames
|
||||
|
||||
|
||||
def capture():
|
||||
return {"http": capture_http(), "openapi_requests": capture_openapi_requests(), "realtime": asyncio.run(capture_realtime())}
|
||||
|
||||
|
||||
class LegacyCompatibilityTests(unittest.TestCase):
|
||||
def test_wire_contracts_match_pre_migration_capture(self):
|
||||
expected = json.loads(FIXTURE.read_text())
|
||||
actual = capture()
|
||||
for name, response in actual["http"].items():
|
||||
with self.subTest(case=name):
|
||||
self.assertEqual(response, expected["http"][name])
|
||||
self.assertEqual(actual["openapi_requests"], expected["openapi_requests"])
|
||||
self.assertEqual(actual["realtime"], expected["realtime"])
|
||||
self.assertEqual(set(actual["http"]), set(expected["http"]))
|
||||
@@ -1,272 +0,0 @@
|
||||
"""Backend contract checks with local HTTP fixtures and a loopback voice server.
|
||||
|
||||
Run from the repository root: .venv/bin/python -m unittest discover -s s2s-client/tests
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import ANY, AsyncMock, Mock, patch
|
||||
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
from websockets.sync.server import serve
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
# 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):
|
||||
import server
|
||||
|
||||
|
||||
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(server, "LOAD_BALANCER_URL", "http://lb.test"))
|
||||
self.enterContext(patch.object(
|
||||
server.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(server, "LOAD_BALANCER_URL", ""), patch.object(
|
||||
server, "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(server, "S2S_LOGIN_BY_PHONE", "http://profile.test/login"), patch.object(
|
||||
server, "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 = server.Request({
|
||||
"type": "http", "headers": [
|
||||
(b"authorization", b"Bearer fixture-token"), (b"x-demo-session", b"fixture-demo"),
|
||||
],
|
||||
})
|
||||
self.assertEqual(server._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(server, "S2S_PROXY_TARGET", target),
|
||||
patch.object(server, "S2S_INJECT_PROFILE", False),
|
||||
patch.object(server, "S2S_APIPILOT_TOOLS_URL", "http://catalog.test/tools"),
|
||||
patch.object(server, "S2S_LOGIN_BY_PHONE", "http://profile.test/login"),
|
||||
patch.object(server, "VOCERO_USER_PHONE_AUTH_URL", "http://profile.test/user"),
|
||||
patch.object(server, "_apipilot_fetch_tools", catalog),
|
||||
patch.object(server, "_login_by_phone", login),
|
||||
patch.object(server, "_fetch_user_profile_for_phone", profile),
|
||||
patch.object(server.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", server.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(server, "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()
|
||||
Reference in New Issue
Block a user