refactor: rename API and frontend projects to Vocero
Rename the project directories, Python distribution and import package, and npm package. Update launcher, Docker, tooling, tests, lockfiles, and documentation while preserving existing API contracts. Make AGENTS.md self-contained and remove external skill and workflow references.
This commit is contained in:
@@ -0,0 +1,573 @@
|
||||
"""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 json
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
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 apipilot
|
||||
from vocero_api.realtime import proxy
|
||||
from vocero_api.services import context
|
||||
|
||||
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": "",
|
||||
"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}, {}, []
|
||||
yield (
|
||||
"reasoning-OPENAI-legacy-unbound-token",
|
||||
"POST",
|
||||
"/api/reasoning",
|
||||
{"json": {"transcript": "hello"}},
|
||||
{"S2S_REASONING_TYPE": "OPENAI"},
|
||||
[],
|
||||
)
|
||||
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": "OPENAI",
|
||||
}
|
||||
reasoning_request = {
|
||||
"json": {
|
||||
"transcript": " hello ",
|
||||
"phoneNumber": "123",
|
||||
"instructions": "Fixture instructions",
|
||||
"language": "es",
|
||||
"future": True,
|
||||
}
|
||||
}
|
||||
yield (
|
||||
"reasoning-openai-answer",
|
||||
"POST",
|
||||
"/api/reasoning",
|
||||
reasoning_request,
|
||||
{
|
||||
**reasoning_config,
|
||||
"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=requests, remaining=remaining):
|
||||
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",
|
||||
)
|
||||
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(settings, **{**BASE_CONFIG, **config}),
|
||||
patch.object(apipilot, "_APIPILOT_CACHE", {}),
|
||||
patch.object(context, "_build_today_fixed_context_block", return_value="Fixture current date: 2026-01-04"),
|
||||
patch.object(
|
||||
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"})
|
||||
|
||||
await proxy._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_native_transcripts_are_relayed_without_external_http_calls(self):
|
||||
events = [
|
||||
{"type": "conversation.item.input_audio_transcription.completed", "transcript": "Hola", "item_id": "u1"},
|
||||
{"type": "response.audio_transcript.done", "transcript": "Respuesta", "response_id": "r1"},
|
||||
{"type": "response.output_audio_transcript.done", "transcript": "Respuesta", "response_id": "r1"},
|
||||
{"type": "response.content_part.done", "part": {"type": "text", "text": "Respuesta"}},
|
||||
*[
|
||||
{
|
||||
"type": event_type,
|
||||
"item": {"id": "a1", "role": "assistant", "content": [{"type": "text", "text": "Respuesta"}]},
|
||||
}
|
||||
for event_type in ("response.output_item.done", "conversation.item.created")
|
||||
],
|
||||
{"type": "response.text.done", "text": "Respuesta", "response_id": "r1"},
|
||||
]
|
||||
messages = [json.dumps(event) for event in events]
|
||||
forwarded = []
|
||||
|
||||
class Socket:
|
||||
async def send_text(self, message):
|
||||
forwarded.append(message)
|
||||
|
||||
async def upstream():
|
||||
for message in messages:
|
||||
yield message
|
||||
|
||||
async def relay():
|
||||
await proxy._upstream_to_client(Socket(), upstream(), "fixture", {})
|
||||
# Allow any accidentally scheduled background HTTP work to run.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
for mode in ("PROXY", "OPENAI"):
|
||||
forwarded.clear()
|
||||
with (
|
||||
self.subTest(mode=mode),
|
||||
patch.multiple(settings, **{**BASE_CONFIG, "S2S_REASONING_TYPE": mode}),
|
||||
patch.object(httpx, "AsyncClient", side_effect=AssertionError("Unexpected HTTP call")) as http,
|
||||
):
|
||||
asyncio.run(relay())
|
||||
self.assertEqual(forwarded, messages)
|
||||
http.assert_not_called()
|
||||
|
||||
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"]))
|
||||
Reference in New Issue
Block a user