test(api): capture legacy HTTP and realtime contracts

This commit is contained in:
2026-10-06 18:10:34 -03:00
parent 3d42df5749
commit 34f0d1b125
2 changed files with 1697 additions and 0 deletions
+275
View File
@@ -0,0 +1,275 @@
"""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"]))