feat: add FastAPI manager API compatibility baseline

This commit is contained in:
Tyke Chen
2026-07-20 17:00:13 +08:00
parent 7c58fa37b2
commit 804ddb51f2
140 changed files with 47169 additions and 1 deletions
@@ -0,0 +1 @@
"""Executable Java/FastAPI compatibility-test support."""
@@ -0,0 +1,341 @@
from __future__ import annotations
import argparse
import json
import re
import string
import sys
import uuid
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
from tests.compatibility.differential_runner import ContractRunner, _build_report
from tests.compatibility.seed_contract_data import ADMIN_TOKEN, SERVER_SECRET
TARGET_ROOT = Path(__file__).resolve().parents[2]
JAVA_MANIFEST = TARGET_ROOT / "compatibility" / "java-routes.json"
PATH_PARAMETER = re.compile(r"\{([^/{}]+)\}")
MISSING_LONG_ID = "9223372036854775806"
PASSWORD_CHARACTERS = frozenset(string.ascii_letters + string.digits + "!@#$%^&*()")
# These create endpoints accept an object whose fields are largely optional. An
# empty JSON object could therefore create an isolated row instead of exercising
# validation. Omitting the body is deterministic on both implementations and
# keeps this runner read-only with respect to intentional business writes.
NO_BODY_ROUTES = {
("POST", "/admin/dict/data/save"),
("POST", "/admin/dict/type/save"),
("POST", "/agent/template"),
("POST", "/agent/voice-print"),
("POST", "/datasets"),
("POST", "/device/manual-add"),
("POST", "/device/register"),
}
MULTIPART_ROUTES = {
("POST", "/datasets/{dataset_id}/documents"),
("POST", "/otaMag/upload"),
("POST", "/otaMag/uploadAssetsBin"),
("POST", "/voiceClone/upload"),
}
NUMERIC_ID_CONTROLLERS = {
"AdminController",
"SysDictDataController",
"SysDictTypeController",
"SysParamsController",
}
# Hibernate Validator exposes constraint violations as a Set, so an empty body
# can legitimately select a different first message on two otherwise identical
# processes. Each payload below leaves exactly one declared constraint invalid;
# the resulting full response remains suitable for an exact comparison and the
# request cannot reach a successful write.
SINGLE_CONSTRAINT_PAYLOADS: dict[tuple[str, str], dict[str, Any]] = {
("POST", "/admin/params"): {
"paramCode": "contract.missing.type",
"paramValue": "contract-value",
},
("PUT", "/admin/params"): {
"id": MISSING_LONG_ID,
"paramCode": "contract.missing.value",
"valueType": "string",
},
("POST", "/admin/server/emit-action"): {
"targetWs": "contract-missing-ws",
},
("POST", "/agent/chat-history/report"): {
"macAddress": "AA:BB:CC:DD:EE:FC",
"sessionId": "contract-missing-content",
"chatType": 1,
},
("POST", "/config/agent-models"): {
"macAddress": "AA:BB:CC:DD:EE:FB",
"clientId": "contract-missing-selected-module",
},
("POST", "/correct-word/file"): {
"fileName": "contract-invalid.txt",
},
("PUT", "/correct-word/file/{fileId}"): {
"fileName": "contract-invalid.txt",
},
("PUT", "/device/address-book/alias"): {
"targetMac": "AA:BB:CC:DD:EE:FD",
},
("PUT", "/device/address-book/permission"): {
"macAddress": "AA:BB:CC:DD:EE:FE",
},
("POST", "/models/provider"): {
"providerCode": "contract-provider-code",
"name": "Contract Provider",
"fields": "[]",
"sort": 0,
},
("POST", "/ttsVoice"): {
"languages": "zh",
"name": "Contract Voice",
"ttsModelId": "contract-missing-model",
},
("PUT", "/ttsVoice/{id}"): {
"languages": "zh",
"ttsModelId": "contract-missing-model",
"ttsVoice": "contract-voice",
},
}
RANDOM_PASSWORD_ROUTE = ("PUT", "/admin/users/{id}")
OTA_DOWNLOAD_UUID_ROUTE = ("GET", "/otaMag/getDownloadUrl/{id}")
@dataclass(frozen=True, slots=True)
class AuthenticatedRouteCase:
method: str
template: str
path: str
auth: str
request_kwargs: dict[str, Any]
@property
def key(self) -> tuple[str, str]:
return self.method, self.template
def _load_routes() -> list[dict[str, Any]]:
manifest = json.loads(JAVA_MANIFEST.read_text(encoding="utf-8"))
routes = cast(list[dict[str, Any]], manifest["routes"])
if manifest["count"] != 154 or len(routes) != 154:
raise RuntimeError("Java route manifest is not closed at 154 routes")
return routes
def _path_value(route: dict[str, Any], name: str) -> str:
controller = str(route["controller"])
if name == "userId" or (name == "id" and controller in NUMERIC_ID_CONTROLLERS):
return MISSING_LONG_ID
if name == "status":
return "0" if controller in {"AdminController", "ModelController"} else "contract-missing-status"
if name == "deviceCode":
return "999999"
if name == "macAddress":
return "AA:BB:CC:DD:EE:FE"
if name == "dictType":
return "CONTRACT_MISSING_DICT_TYPE"
if name == "modelType":
return "CONTRACT_MISSING_MODEL_TYPE"
if name == "provideCode":
return "contract-missing-provider"
return f"contract-missing-{name.lower()}"
def _safe_path(route: dict[str, Any]) -> str:
template = str(route["path"])
return PATH_PARAMETER.sub(lambda match: _path_value(route, match.group(1)), template)
def _headers(auth: str) -> dict[str, str]:
if auth == "database-token":
return {"Authorization": f"Bearer {ADMIN_TOKEN}"}
if auth == "server-secret":
return {"Authorization": f"Bearer {SERVER_SECRET}"}
return {}
def build_cases() -> list[AuthenticatedRouteCase]:
cases: list[AuthenticatedRouteCase] = []
for route in _load_routes():
method = str(route["method"])
template = str(route["path"])
auth = str(route["auth"])
kwargs: dict[str, Any] = {}
headers = _headers(auth)
if headers:
kwargs["headers"] = headers
key = (method, template)
if key in SINGLE_CONSTRAINT_PAYLOADS:
kwargs["json"] = SINGLE_CONSTRAINT_PAYLOADS[key]
elif method in {"POST", "PUT"} and key not in MULTIPART_ROUTES and key not in NO_BODY_ROUTES:
kwargs["json"] = {}
cases.append(
AuthenticatedRouteCase(
method=method,
template=template,
path=_safe_path(route),
auth=auth,
request_kwargs=kwargs,
)
)
return cases
def _valid_random_password(value: Any) -> bool:
return (
isinstance(value, str)
and len(value) == 12
and set(value) <= PASSWORD_CHARACTERS
and any(character in string.digits for character in value)
and any(character in string.ascii_lowercase for character in value)
and any(character in string.ascii_uppercase for character in value)
and any(character in "!@#$%^&*()" for character in value)
)
def _valid_uuid4(value: Any) -> bool:
if not isinstance(value, str):
return False
try:
parsed = uuid.UUID(value)
except (ValueError, AttributeError):
return False
return parsed.version == 4 and str(parsed) == value
def _normalize_dynamic_data(
value: Any,
*,
validator: Callable[[Any], bool],
placeholder: str,
) -> Any:
if (
isinstance(value, dict)
and value.get("code") == 0
and validator(value.get("data"))
):
return {**value, "data": placeholder}
return value
def _normalize_random_password(value: Any) -> Any:
return _normalize_dynamic_data(
value,
validator=_valid_random_password,
placeholder="<valid-random-password>",
)
def _normalize_ota_download_uuid(value: Any) -> Any:
return _normalize_dynamic_data(
value,
validator=_valid_uuid4,
placeholder="<valid-uuid-v4>",
)
DYNAMIC_RESPONSES: dict[
tuple[str, str], tuple[Callable[[Any], Any], Callable[[Any], bool], str]
] = {
RANDOM_PASSWORD_ROUTE: (
_normalize_random_password,
_valid_random_password,
"random_password",
),
OTA_DOWNLOAD_UUID_ROUTE: (
_normalize_ota_download_uuid,
_valid_uuid4,
"uuid_v4",
),
}
def _validate_dynamic_response(
runner: ContractRunner,
*,
java_body: Any,
fastapi_body: Any,
validator: Callable[[Any], bool],
label: str,
) -> None:
result = runner.results[-1]
validity = {
f"java:{label}": isinstance(java_body, dict)
and java_body.get("code") == 0
and validator(java_body.get("data")),
f"fastapi:{label}": isinstance(fastapi_body, dict)
and fastapi_body.get("code") == 0
and validator(fastapi_body.get("data")),
}
result.checks.update(validity)
result.passed = all(result.checks.values())
failed = [name for name, passed in validity.items() if not passed]
if failed and result.difference is None:
result.difference = "invalid dynamic response format: " + ", ".join(failed)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--java-base", default="http://127.0.0.1:18082/xiaozhi")
parser.add_argument("--fastapi-base", default="http://127.0.0.1:18083/xiaozhi")
parser.add_argument("--mock-base", default="http://127.0.0.1:18084")
parser.add_argument("--mysql-port", type=int, default=13316)
parser.add_argument(
"--output",
type=Path,
default=Path("compatibility/authenticated-route-results.json"),
)
args = parser.parse_args()
runner = ContractRunner(args.java_base, args.fastapi_base, args.mock_base, args.mysql_port)
try:
for case in build_cases():
dynamic = DYNAMIC_RESPONSES.get(case.key)
java, fastapi = runner.request_pair(
f"{case.method} {case.template}",
f"authenticated-route:{case.auth}",
case.method,
case.path,
normalize=dynamic[0] if dynamic else None,
exact_headers=("content-type",),
**case.request_kwargs,
)
if dynamic:
_validate_dynamic_response(
runner,
java_body=java.body,
fastapi_body=fastapi.body,
validator=dynamic[1],
label=dynamic[2],
)
finally:
runner.close()
report = _build_report(runner)
report["coverage"] = {
"java_routes": len(runner.results),
"request_profile": "authenticated-safe-business-or-validation",
"side_effect_policy": "no intentional successful writes",
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n",
encoding="utf-8",
)
summary = report["summary"]
print(json.dumps(summary, ensure_ascii=False))
for result in runner.results:
if not result.passed:
print(f"FAIL {result.name}: {result.difference}")
sys.exit(1 if summary["failed"] else 0)
if __name__ == "__main__":
main()
@@ -0,0 +1,952 @@
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import json
import re
import sys
from collections.abc import Callable, Mapping
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, cast
import asyncmy # type: ignore[import-untyped]
import httpx
from asyncmy.cursors import DictCursor # type: ignore[import-untyped]
from tests.compatibility.seed_contract_data import (
ADMIN_ID,
ADMIN_TOKEN,
EXPIRED_TOKEN,
MQTT_SIGNATURE_KEY,
NORMAL_ID,
NORMAL_TOKEN,
SERVER_SECRET,
)
JSONValue = dict[str, Any] | list[Any] | str | int | float | bool | None
Normalizer = Callable[[JSONValue], JSONValue]
SENSITIVE_REPORT_KEYS = {
"accesstoken",
"authorization",
"mqttsignaturekey",
"password",
"privatekey",
"refreshtoken",
"secret",
"serversecret",
"token",
"websockettoken",
}
@dataclass
class ContractResult:
name: str
category: str
passed: bool
checks: dict[str, bool]
difference: str | None
java: dict[str, Any]
fastapi: dict[str, Any]
@dataclass
class CapturedResponse:
status: int
headers: dict[str, str | None]
body: JSONValue
body_sha256: str
class ContractRunner:
def __init__(
self,
java_base: str,
fastapi_base: str,
mock_base: str,
mysql_port: int,
):
self.bases = {"java": java_base.rstrip("/"), "fastapi": fastapi_base.rstrip("/")}
self.mock_base = mock_base.rstrip("/")
self.mysql_port = mysql_port
self.client = httpx.Client(timeout=20.0, follow_redirects=False)
self.results: list[ContractResult] = []
def close(self) -> None:
self.client.close()
@staticmethod
def _capture(response: httpx.Response) -> CapturedResponse:
content_type = response.headers.get("content-type")
try:
body: JSONValue = response.json()
except (json.JSONDecodeError, UnicodeDecodeError):
try:
body = response.content.decode("utf-8")
except UnicodeDecodeError:
body = {"base64": base64.b64encode(response.content).decode()}
return CapturedResponse(
status=response.status_code,
headers={
"content-type": content_type,
"content-disposition": response.headers.get("content-disposition"),
"content-length": response.headers.get("content-length"),
},
body=body,
body_sha256=hashlib.sha256(response.content).hexdigest(),
)
def request_pair(
self,
name: str,
category: str,
method: str,
path: str,
*,
normalize: Normalizer | None = None,
exact_headers: tuple[str, ...] = (),
**kwargs: Any,
) -> tuple[CapturedResponse, CapturedResponse]:
captured: dict[str, CapturedResponse] = {}
for side, base in self.bases.items():
captured[side] = self._capture(self.client.request(method, base + path, **kwargs))
java, fastapi = captured["java"], captured["fastapi"]
java_body = normalize(java.body) if normalize else java.body
fastapi_body = normalize(fastapi.body) if normalize else fastapi.body
checks = {
"http_status": java.status == fastapi.status,
"body": java_body == fastapi_body,
}
for header in exact_headers:
checks[f"header:{header}"] = java.headers.get(header) == fastapi.headers.get(header)
difference = _first_difference(java_body, fastapi_body)
if difference is None and not checks["http_status"]:
difference = f"HTTP status Java={java.status} FastAPI={fastapi.status}"
if difference is None:
for check, passed in checks.items():
if not passed:
difference = (
f"{check} Java={java.headers.get(check.removeprefix('header:'))!r} "
f"FastAPI={fastapi.headers.get(check.removeprefix('header:'))!r}"
)
break
self.results.append(
ContractResult(
name=name,
category=category,
passed=all(checks.values()),
checks=checks,
difference=difference,
java=_response_summary(java, java_body),
fastapi=_response_summary(fastapi, fastapi_body),
)
)
return java, fastapi
def add_custom(
self,
name: str,
category: str,
checks: Mapping[str, bool],
*,
difference: str | None = None,
java: Mapping[str, Any] | None = None,
fastapi: Mapping[str, Any] | None = None,
) -> None:
failed_checks = [check for check, passed in checks.items() if not passed]
self.results.append(
ContractResult(
name=name,
category=category,
passed=not failed_checks,
checks=dict(checks),
difference=difference or (", ".join(failed_checks) if failed_checks else None),
java=dict(java or {}),
fastapi=dict(fastapi or {}),
)
)
def run_read_only_cases(self) -> None:
self.request_pair("public configuration", "configuration", "GET", "/user/pub-config")
languages: list[str | None] = [None, "zh-CN", "zh-TW", "en-US", "de-DE", "vi-VN", "pt-BR"]
for language in languages:
headers = {} if language is None else {"Accept-Language": language}
self.request_pair(
f"unauthenticated localized response [{language or 'default'}]",
"authentication-i18n",
"GET",
"/user/info",
headers=headers,
exact_headers=("content-type",),
)
self.request_pair(
"expired database token",
"authentication",
"GET",
"/user/info",
headers=_bearer(EXPIRED_TOKEN),
exact_headers=("content-type",),
)
self.request_pair(
"normal user forbidden from admin endpoint",
"authorization",
"GET",
"/admin/users",
headers=_bearer(NORMAL_TOKEN),
)
self.request_pair(
"Long user ID and token response",
"serialization",
"GET",
"/user/info",
headers=_bearer(NORMAL_TOKEN),
)
self.request_pair(
"user page Long/date/null structure",
"serialization",
"GET",
"/admin/users?page=1&limit=10",
headers=_bearer(ADMIN_TOKEN),
)
self.request_pair(
"agent list null/date fields",
"agent",
"GET",
"/agent/list",
headers=_bearer(NORMAL_TOKEN),
)
self.request_pair(
"device list UTC display and epoch fields",
"device",
"GET",
"/device/bind/contract-agent-1",
headers=_bearer(NORMAL_TOKEN),
)
self.request_pair(
"filtered model provider page",
"model",
"GET",
"/models/provider?page=1&limit=10&name=Contract%20Provider",
headers=_bearer(ADMIN_TOKEN),
)
self.request_pair(
"correct-word page",
"correct-word",
"GET",
"/correct-word/file/list?page=1&limit=10",
headers=_bearer(NORMAL_TOKEN),
)
self.request_pair(
"correct-word binary download headers and bytes",
"binary-download",
"GET",
"/correct-word/file/download/contract-words",
headers=_bearer(NORMAL_TOKEN),
exact_headers=("content-type", "content-disposition", "content-length"),
)
def run_validation_cases(self) -> None:
normal = _bearer(NORMAL_TOKEN)
admin = _bearer(ADMIN_TOKEN)
self.request_pair(
"minimum boundary validation",
"validation",
"PUT",
"/device/update/contract-device-1",
headers=normal,
json={"autoUpdate": -1},
)
self.request_pair(
"maximum boundary validation",
"validation",
"PUT",
"/device/update/contract-device-1",
headers=normal,
json={"autoUpdate": 2},
)
self.request_pair(
"UTF-16 string-length validation boundary",
"validation",
"PUT",
"/device/update/contract-device-1",
headers=normal,
json={"alias": "x" * 65},
)
# Hibernate Validator returns a Set of constraint violations, so the
# first of these five declared annotations is intentionally unstable.
# Validate the exact declared set and envelope, not one random order.
declared_messages = {
"modelType不能为空",
"providerCode不能为空",
"name不能为空",
"fields(JSON格式)不能为空",
"sort不能为空",
}
java_responses = [
self._capture(
self.client.post(
self.bases["java"] + "/models/provider",
headers=admin,
json={},
)
)
for _ in range(10)
]
fastapi_response = self._capture(
self.client.post(
self.bases["fastapi"] + "/models/provider",
headers=admin,
json={},
)
)
java_messages = {
str(response.body.get("msg"))
for response in java_responses
if isinstance(response.body, dict)
}
fastapi_message = (
str(fastapi_response.body.get("msg")) if isinstance(fastapi_response.body, dict) else None
)
self.add_custom(
"required model-provider fields (unordered Java ConstraintViolation Set)",
"validation",
{
"java_http_200": all(response.status == 200 for response in java_responses),
"java_code_10034": all(
isinstance(response.body, dict) and response.body.get("code") == 10034
for response in java_responses
),
"java_messages_are_declared_constraints": bool(java_messages)
and java_messages <= declared_messages,
"fastapi_http_200": fastapi_response.status == 200,
"fastapi_code_10034": isinstance(fastapi_response.body, dict)
and fastapi_response.body.get("code") == 10034,
"fastapi_message_is_declared_constraint": fastapi_message in declared_messages,
},
java={"observed_messages": sorted(java_messages), "declared_messages": sorted(declared_messages)},
fastapi={"body": fastapi_response.body},
)
self.request_pair(
"invalid pagination number format",
"validation",
"GET",
"/admin/users?page=bad&limit=10",
headers=admin,
)
def run_server_auth_and_config(self) -> None:
self.request_pair(
"missing server secret",
"server-secret",
"POST",
"/config/server-base",
exact_headers=("content-type",),
)
self.request_pair(
"invalid server secret",
"server-secret",
"POST",
"/config/server-base",
headers=_bearer("invalid-contract-secret"),
exact_headers=("content-type",),
)
self.request_pair(
"valid server secret runtime configuration",
"server-secret",
"POST",
"/config/server-base",
headers=_bearer(SERVER_SECRET),
)
def run_ota_cases(self) -> None:
self.request_pair(
"OTA health MIME and body",
"ota",
"GET",
"/ota/",
exact_headers=("content-type",),
)
self.request_pair(
"OTA missing required Device-Id",
"ota-validation",
"POST",
"/ota/",
json={"application": {"version": "1.2.3"}, "board": {"type": "esp32s3"}},
)
self.request_pair(
"OTA invalid Device-Id",
"ota-validation",
"POST",
"/ota/",
headers={"Device-Id": "not-a-mac"},
json={"application": {"version": "1.2.3"}, "board": {"type": "esp32s3"}},
exact_headers=("content-type", "content-length"),
)
java, fastapi = self.request_pair(
"OTA WebSocket/MQTT credentials",
"ota-signing",
"POST",
"/ota/",
headers={"Device-Id": "AA:BB:CC:DD:EE:01", "Client-Id": "contract-client"},
json={"application": {"version": "1.2.3"}, "board": {"type": "esp32s3"}},
normalize=_normalize_ota_dynamic,
exact_headers=("content-type", "content-length"),
)
self.add_custom(
"OTA HMAC/Base64 cryptographic validity",
"ota-signing",
{
"java_credentials": _valid_ota_credentials(java.body),
"fastapi_credentials": _valid_ota_credentials(fastapi.body),
},
java={"credential_shape": _ota_credential_shape(java.body)},
fastapi={"credential_shape": _ota_credential_shape(fastapi.body)},
)
self.request_pair(
"activation missing Device-Id",
"activation",
"POST",
"/ota/activate",
)
self.request_pair(
"activation unknown device",
"activation",
"POST",
"/ota/activate",
headers={"Device-Id": "FF:EE:DD:CC:BB:AA"},
)
self.request_pair(
"activation bound device MIME",
"activation",
"POST",
"/ota/activate",
headers={"Device-Id": "AA:BB:CC:DD:EE:01"},
exact_headers=("content-type",),
)
def run_external_mock_case(self) -> None:
self.client.delete(self.mock_base + "/__requests")
java, fastapi = self.request_pair(
"MQTT tools external mock response",
"external-mock",
"POST",
"/device/tools/list/contract-device-1",
headers=_bearer(NORMAL_TOKEN),
)
records = self.client.get(self.mock_base + "/__requests").json().get("requests", [])
recent = [record for record in records if str(record.get("path", "")).startswith("/api/commands/")]
expected_token = hashlib.sha256(
f"{datetime.now(tz=timezone.utc).date().isoformat()}{MQTT_SIGNATURE_KEY}".encode()
).hexdigest()
auth_values = [record.get("headers", {}).get("authorization") for record in recent]
bodies = [record.get("body") for record in recent]
self.add_custom(
"MQTT external request format and daily auth",
"external-mock",
{
"both_services_called_mock": len(recent) == 2,
"request_bodies_equal": len(bodies) == 2 and bodies[0] == bodies[1],
"daily_tokens_valid": len(auth_values) == 2
and all(value == f"Bearer {expected_token}" for value in auth_values),
"responses_compatible": java.body == fastapi.body,
},
java={"request": recent[0] if recent else None},
fastapi={"request": recent[1] if len(recent) > 1 else None},
)
def run_correct_word_crud(self) -> None:
payload = {
"fileName": "contract-run-created.txt",
"content": ["xiaozhi|小智", "ESP32|esp32"],
"fileSize": 31,
}
responses: dict[str, CapturedResponse] = {}
identifiers: dict[str, str] = {}
for side, base in self.bases.items():
response = self._capture(
self.client.post(base + "/correct-word/file", headers=_bearer(NORMAL_TOKEN), json=payload)
)
responses[side] = response
if isinstance(response.body, dict):
data = response.body.get("data")
if isinstance(data, dict) and isinstance(data.get("id"), str):
identifiers[side] = data["id"]
java_normalized = _normalize_created_correct_word(responses["java"].body)
fastapi_normalized = _normalize_created_correct_word(responses["fastapi"].body)
self.add_custom(
"correct-word create response",
"crud",
{
"http_status": responses["java"].status == responses["fastapi"].status,
"body": java_normalized == fastapi_normalized,
"ids_returned": set(identifiers) == {"java", "fastapi"},
},
difference=_first_difference(java_normalized, fastapi_normalized),
java={"body": java_normalized},
fastapi={"body": fastapi_normalized},
)
if set(identifiers) != {"java", "fastapi"}:
return
side_effects = {
side: _database_row(
self.mysql_port,
"manager_java_test" if side == "java" else "manager_fastapi_test",
"SELECT file_name,word_count,content,creator FROM ai_agent_correct_word_file WHERE id=%s",
(identifier,),
)
for side, identifier in identifiers.items()
}
self.add_custom(
"correct-word create database side effect",
"database-side-effect",
{"row_equal": side_effects["java"] == side_effects["fastapi"]},
difference=_first_difference(side_effects["java"], side_effects["fastapi"]),
java=side_effects["java"],
fastapi=side_effects["fastapi"],
)
update = {"fileName": "contract-run-updated.txt", "content": ["A|B"], "fileSize": 3}
update_responses: dict[str, CapturedResponse] = {}
for side, identifier in identifiers.items():
update_responses[side] = self._capture(
self.client.put(
self.bases[side] + f"/correct-word/file/{identifier}",
headers=_bearer(NORMAL_TOKEN),
json=update,
)
)
self.add_custom(
"correct-word update response",
"crud",
{
"status": update_responses["java"].status == update_responses["fastapi"].status,
"body": update_responses["java"].body == update_responses["fastapi"].body,
},
difference=_first_difference(update_responses["java"].body, update_responses["fastapi"].body),
java={"body": update_responses["java"].body},
fastapi={"body": update_responses["fastapi"].body},
)
updated_rows = {
side: _database_row(
self.mysql_port,
"manager_java_test" if side == "java" else "manager_fastapi_test",
"SELECT file_name,word_count,content,creator,updater FROM ai_agent_correct_word_file WHERE id=%s",
(identifier,),
)
for side, identifier in identifiers.items()
}
self.add_custom(
"correct-word update database side effect",
"database-side-effect",
{"row_equal": updated_rows["java"] == updated_rows["fastapi"]},
difference=_first_difference(updated_rows["java"], updated_rows["fastapi"]),
java=updated_rows["java"],
fastapi=updated_rows["fastapi"],
)
downloads = {
side: self._capture(
self.client.get(
self.bases[side] + f"/correct-word/file/download/{identifier}",
headers=_bearer(NORMAL_TOKEN),
)
)
for side, identifier in identifiers.items()
}
self.add_custom(
"correct-word updated binary download",
"binary-download",
{
"status": downloads["java"].status == downloads["fastapi"].status,
"bytes": downloads["java"].body_sha256 == downloads["fastapi"].body_sha256,
"content_type": downloads["java"].headers["content-type"]
== downloads["fastapi"].headers["content-type"],
"content_disposition": downloads["java"].headers["content-disposition"]
== downloads["fastapi"].headers["content-disposition"],
"content_length": downloads["java"].headers["content-length"]
== downloads["fastapi"].headers["content-length"],
},
java=asdict(downloads["java"]),
fastapi=asdict(downloads["fastapi"]),
)
for side, identifier in identifiers.items():
self.client.delete(
self.bases[side] + f"/correct-word/file/{identifier}",
headers=_bearer(NORMAL_TOKEN),
)
counts = {
side: _database_scalar(
self.mysql_port,
"manager_java_test" if side == "java" else "manager_fastapi_test",
"SELECT COUNT(*) FROM ai_agent_correct_word_file WHERE id=%s",
(identifier,),
)
for side, identifier in identifiers.items()
}
self.add_custom(
"correct-word delete cascade side effect",
"database-side-effect",
{"both_deleted": counts == {"java": 0, "fastapi": 0}},
java={"remaining": counts["java"]},
fastapi={"remaining": counts["fastapi"]},
)
def run_ota_upload_download(self) -> None:
firmware = b"manager-api-contract-firmware\x00\xff"
upload_responses: dict[str, CapturedResponse] = {}
paths: dict[str, str] = {}
for side, base in self.bases.items():
response = self._capture(
self.client.post(
base + "/otaMag/upload",
headers=_bearer(ADMIN_TOKEN),
files={"file": ("contract.bin", firmware, "application/octet-stream")},
)
)
upload_responses[side] = response
if isinstance(response.body, dict) and isinstance(response.body.get("data"), str):
paths[side] = response.body["data"]
self.add_custom(
"OTA multipart upload response",
"upload",
{
"status": upload_responses["java"].status == upload_responses["fastapi"].status,
"body": upload_responses["java"].body == upload_responses["fastapi"].body,
"paths_returned": set(paths) == {"java", "fastapi"},
},
difference=_first_difference(upload_responses["java"].body, upload_responses["fastapi"].body),
java={"body": upload_responses["java"].body},
fastapi={"body": upload_responses["fastapi"].body},
)
self.request_pair(
"OTA multipart extension validation",
"upload-validation",
"POST",
"/otaMag/upload",
headers=_bearer(ADMIN_TOKEN),
files={"file": ("contract.txt", b"invalid", "text/plain")},
)
if set(paths) != {"java", "fastapi"}:
return
create_responses: dict[str, CapturedResponse] = {}
for side, base in self.bases.items():
create_responses[side] = self._capture(
self.client.post(
base + "/otaMag",
headers=_bearer(ADMIN_TOKEN),
json={
"id": "contract-ota-run",
"firmwareName": "Contract Firmware",
"type": "contract-run",
"version": "1.0.0",
"size": len(firmware),
"remark": None,
"firmwarePath": paths[side],
"sort": 9,
},
)
)
self.add_custom(
"OTA metadata create response",
"crud",
{
"status": create_responses["java"].status == create_responses["fastapi"].status,
"body": create_responses["java"].body == create_responses["fastapi"].body,
},
difference=_first_difference(create_responses["java"].body, create_responses["fastapi"].body),
java={"body": create_responses["java"].body},
fastapi={"body": create_responses["fastapi"].body},
)
rows = {
side: _database_row(
self.mysql_port,
"manager_java_test" if side == "java" else "manager_fastapi_test",
"SELECT id,firmware_name,type,version,size,remark,firmware_path,sort FROM ai_ota WHERE id=%s",
("contract-ota-run",),
)
for side in self.bases
}
self.add_custom(
"OTA metadata database side effect",
"database-side-effect",
{"row_equal": rows["java"] == rows["fastapi"]},
difference=_first_difference(rows["java"], rows["fastapi"]),
java=rows["java"],
fastapi=rows["fastapi"],
)
download_ids: dict[str, str] = {}
for side, base in self.bases.items():
response = self.client.get(
base + "/otaMag/getDownloadUrl/contract-ota-run",
headers=_bearer(ADMIN_TOKEN),
).json()
if isinstance(response.get("data"), str):
download_ids[side] = response["data"]
if set(download_ids) != {"java", "fastapi"}:
self.add_custom(
"OTA download token generation",
"binary-download",
{"tokens_returned": False},
java={"id": download_ids.get("java")},
fastapi={"id": download_ids.get("fastapi")},
)
return
for attempt in range(1, 5):
responses = {
side: self._capture(self.client.get(self.bases[side] + f"/otaMag/download/{identifier}"))
for side, identifier in download_ids.items()
}
self.add_custom(
f"OTA binary download attempt {attempt}",
"binary-download",
{
"status": responses["java"].status == responses["fastapi"].status,
"bytes": responses["java"].body_sha256 == responses["fastapi"].body_sha256,
"content_type": responses["java"].headers["content-type"]
== responses["fastapi"].headers["content-type"],
"content_disposition": responses["java"].headers["content-disposition"]
== responses["fastapi"].headers["content-disposition"],
"content_length": responses["java"].headers["content-length"]
== responses["fastapi"].headers["content-length"],
},
java=asdict(responses["java"]),
fastapi=asdict(responses["fastapi"]),
)
def _bearer(value: str) -> dict[str, str]:
return {"Authorization": f"Bearer {value}"}
def _response_summary(response: CapturedResponse, normalized_body: JSONValue) -> dict[str, Any]:
report_body = _redact_report_value(normalized_body)
body_text = json.dumps(report_body, ensure_ascii=False, sort_keys=True, default=str)
return {
"status": response.status,
"headers": response.headers,
"body": report_body if len(body_text) <= 8_000 else None,
"body_sha256": hashlib.sha256(body_text.encode()).hexdigest(),
"body_excerpt": body_text[:1_000],
}
def _redact_report_value(value: Any) -> Any:
if isinstance(value, dict):
redacted: dict[str, Any] = {}
parameter_code = next(
(
item
for key, item in value.items()
if re.sub(r"[^a-z0-9]", "", str(key).lower()) == "paramcode"
),
None,
)
sensitive_parameter = (
isinstance(parameter_code, str)
and re.sub(r"[^a-z0-9]", "", parameter_code.lower()) in SENSITIVE_REPORT_KEYS
)
for key, item in value.items():
normalized_key = re.sub(r"[^a-z0-9]", "", str(key).lower())
redacted[str(key)] = (
"<redacted>"
if normalized_key in SENSITIVE_REPORT_KEYS
or sensitive_parameter and normalized_key == "paramvalue"
else _redact_report_value(item)
)
return redacted
if isinstance(value, list):
return [_redact_report_value(item) for item in value]
if isinstance(value, tuple):
return [_redact_report_value(item) for item in value]
return value
def _first_difference(left: Any, right: Any, path: str = "$") -> str | None:
if type(left) is not type(right):
return f"{path}: type Java={type(left).__name__} FastAPI={type(right).__name__}"
if isinstance(left, dict):
left_keys, right_keys = set(left), set(right)
if left_keys != right_keys:
return (
f"{path}: keys Java-only={sorted(left_keys - right_keys)} "
f"FastAPI-only={sorted(right_keys - left_keys)}"
)
for key in sorted(left):
difference = _first_difference(left[key], right[key], f"{path}.{key}")
if difference:
return difference
return None
if isinstance(left, list):
if len(left) != len(right):
return f"{path}: length Java={len(left)} FastAPI={len(right)}"
for index, (left_item, right_item) in enumerate(zip(left, right, strict=True)):
difference = _first_difference(left_item, right_item, f"{path}[{index}]")
if difference:
return difference
return None
if left != right:
return f"{path}: Java={left!r} FastAPI={right!r}"
return None
def _normalize_ota_dynamic(value: JSONValue) -> JSONValue:
copied = json.loads(json.dumps(value, ensure_ascii=False))
if isinstance(copied, dict):
server_time = copied.get("server_time")
if isinstance(server_time, dict) and isinstance(server_time.get("timestamp"), int):
server_time["timestamp"] = "<millisecond-timestamp>"
websocket = copied.get("websocket")
if isinstance(websocket, dict) and isinstance(websocket.get("token"), str):
websocket["token"] = "<validated-websocket-token>" # noqa: S105 - redaction marker
return cast(JSONValue, copied)
def _valid_ota_credentials(value: JSONValue) -> bool:
if not isinstance(value, dict):
return False
websocket, mqtt = value.get("websocket"), value.get("mqtt")
if not isinstance(websocket, dict) or not isinstance(mqtt, dict):
return False
token = websocket.get("token")
if not isinstance(token, str) or "." not in token:
return False
encoded, raw_timestamp = token.rsplit(".", 1)
if not raw_timestamp.isdigit():
return False
expected = base64.urlsafe_b64encode(
hmac.new(
SERVER_SECRET.encode(),
f"contract-client|AA:BB:CC:DD:EE:01|{raw_timestamp}".encode(),
hashlib.sha256,
).digest()
).decode().rstrip("=")
if not hmac.compare_digest(encoded, expected):
return False
client_id, username, password = mqtt.get("client_id"), mqtt.get("username"), mqtt.get("password")
if not all(isinstance(item, str) for item in (client_id, username, password)):
return False
expected_password = base64.b64encode(
hmac.new(
MQTT_SIGNATURE_KEY.encode(),
f"{client_id}|{username}".encode(),
hashlib.sha256,
).digest()
).decode()
try:
user_data = json.loads(base64.b64decode(str(username)).decode())
except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
return False
return hmac.compare_digest(str(password), expected_password) and isinstance(user_data.get("ip"), str)
def _ota_credential_shape(value: JSONValue) -> dict[str, Any]:
if not isinstance(value, dict):
return {"valid": False}
websocket, mqtt = value.get("websocket"), value.get("mqtt")
return {
"websocket_token_pattern": bool(
isinstance(websocket, dict)
and re.fullmatch(r"[A-Za-z0-9_-]{43}\.\d{10}", str(websocket.get("token", "")))
),
"mqtt_fields": sorted(mqtt) if isinstance(mqtt, dict) else [],
}
def _normalize_created_correct_word(value: JSONValue) -> JSONValue:
copied = json.loads(json.dumps(value, ensure_ascii=False))
if isinstance(copied, dict) and isinstance(copied.get("data"), dict):
data = copied["data"]
if isinstance(data.get("id"), str):
data["id"] = "<generated-id>"
for field in ("createdAt", "updatedAt"):
if data.get(field) is not None:
data[field] = "<datetime>"
return cast(JSONValue, copied)
def _database_config(port: int, database: str) -> dict[str, Any]:
return {
"host": "127.0.0.1",
"port": port,
"user": "xiaozhi_test",
"password": "isolated-test-only",
"db": database,
"charset": "utf8mb4",
"autocommit": True,
}
def _database_row(port: int, database: str, sql: str, params: tuple[Any, ...]) -> dict[str, Any]:
async def query() -> dict[str, Any]:
connection = await asyncmy.connect(**_database_config(port, database))
try:
async with connection.cursor(DictCursor) as cursor:
await cursor.execute(sql, params)
row = await cursor.fetchone()
return {} if row is None else {str(key): value for key, value in row.items()}
finally:
connection.close()
import asyncio
return asyncio.run(query())
def _database_scalar(port: int, database: str, sql: str, params: tuple[Any, ...]) -> int:
row = _database_row(port, database, sql, params)
return int(next(iter(row.values()), 0))
def _build_report(runner: ContractRunner) -> dict[str, Any]:
passed = sum(result.passed for result in runner.results)
failed = len(runner.results) - passed
categories: dict[str, dict[str, int]] = {}
for result in runner.results:
category = categories.setdefault(result.category, {"passed": 0, "failed": 0})
category["passed" if result.passed else "failed"] += 1
return {
"schema_version": 1,
"generated_at": datetime.now(tz=timezone.utc).isoformat(),
"services": runner.bases,
"fixture_ids": {"admin": str(ADMIN_ID), "normal": str(NORMAL_ID)},
"summary": {"total": len(runner.results), "passed": passed, "failed": failed, "skipped": 0},
"categories": categories,
"results": [_redact_report_value(asdict(result)) for result in runner.results],
}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--java-base", default="http://127.0.0.1:18082/xiaozhi")
parser.add_argument("--fastapi-base", default="http://127.0.0.1:18083/xiaozhi")
parser.add_argument("--mock-base", default="http://127.0.0.1:18084")
parser.add_argument("--mysql-port", type=int, default=13316)
parser.add_argument("--output", type=Path, default=Path("compatibility/contract-results.json"))
args = parser.parse_args()
runner = ContractRunner(args.java_base, args.fastapi_base, args.mock_base, args.mysql_port)
try:
runner.run_read_only_cases()
runner.run_validation_cases()
runner.run_server_auth_and_config()
runner.run_ota_cases()
runner.run_external_mock_case()
runner.run_correct_word_crud()
runner.run_ota_upload_download()
finally:
runner.close()
report = _build_report(runner)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n", encoding="utf-8")
summary = report["summary"]
print(json.dumps(summary, ensure_ascii=False))
for result in runner.results:
if not result.passed:
print(f"FAIL {result.name}: {result.difference}")
sys.exit(1 if summary["failed"] else 0)
if __name__ == "__main__":
main()
@@ -0,0 +1,229 @@
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
from collections.abc import Mapping
from typing import Annotated, Any
from fastapi import FastAPI, File, Request, UploadFile
from fastapi.responses import JSONResponse
app = FastAPI(title="manager-api repeatable external-service mock")
_requests: list[dict[str, Any]] = []
_datasets: dict[str, dict[str, Any]] = {}
_documents: dict[str, dict[str, Any]] = {}
async def _capture(request: Request, body: Any = None) -> None:
_requests.append(
{
"method": request.method,
"path": request.url.path,
"query": dict(request.query_params),
"headers": {
key.lower(): value
for key, value in request.headers.items()
if key.lower() in {"authorization", "content-type"}
},
"body": body,
}
)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "UP"}
@app.get("/__requests")
async def requests() -> dict[str, Any]:
return {"requests": list(_requests)}
@app.delete("/__requests")
async def reset_requests() -> dict[str, bool]:
_requests.clear()
return {"reset": True}
@app.post("/api/commands/{client_id}")
async def mqtt_command(client_id: str, request: Request) -> JSONResponse:
body = await request.json()
await _capture(request, body)
method = body.get("payload", {}).get("method") if isinstance(body, Mapping) else None
if method == "tools/list":
response = {
"success": True,
"data": {
"tools": [
{
"name": "contract.echo",
"description": "repeatable contract fixture",
"inputSchema": {"type": "object"},
}
]
},
}
elif method == "tools/call":
response = {"success": True, "data": {"content": [{"type": "text", "text": '{"echo":true}'}]}}
else:
response = {"success": True, "data": {"clientId": client_id}}
return JSONResponse(response)
@app.post("/api/devices/status")
async def mqtt_status(request: Request) -> JSONResponse:
body = await request.json()
await _capture(request, body)
client_ids = body.get("clientIds", []) if isinstance(body, Mapping) else []
return JSONResponse({"success": True, "data": {str(value): True for value in client_ids}})
@app.post("/api/call/{operation}")
async def mqtt_call(operation: str, request: Request) -> JSONResponse:
body = await request.json()
await _capture(request, body)
return JSONResponse({"status": "success", "message": operation})
@app.post("/v1/chat/completions")
async def chat_completions(request: Request) -> JSONResponse:
body = await request.json()
await _capture(request, body)
return JSONResponse(
{
"id": "chatcmpl-contract",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "contract summary"}}],
}
)
@app.get("/api/v1/datasets")
async def list_datasets(request: Request) -> JSONResponse:
await _capture(request)
dataset_id = request.query_params.get("id")
values = list(_datasets.values())
if dataset_id:
values = [value for value in values if value["id"] == dataset_id]
return JSONResponse({"code": 0, "data": values})
@app.post("/api/v1/datasets")
async def create_dataset(request: Request) -> JSONResponse:
body = await request.json()
await _capture(request, body)
identifier = "rag-contract-" + hashlib.sha256(
json.dumps(body, ensure_ascii=False, sort_keys=True).encode()
).hexdigest()[:12]
value = {
"id": identifier,
"name": body.get("name"),
"description": body.get("description"),
"tenant_id": "tenant-contract",
"avatar": body.get("avatar"),
"document_count": 0,
"chunk_count": 0,
"token_num": 0,
}
_datasets[identifier] = value
return JSONResponse({"code": 0, "data": value})
@app.put("/api/v1/datasets/{dataset_id}")
async def update_dataset(dataset_id: str, request: Request) -> JSONResponse:
body = await request.json()
await _capture(request, body)
value = _datasets.setdefault(dataset_id, {"id": dataset_id})
value.update(body)
return JSONResponse({"code": 0, "data": value})
@app.delete("/api/v1/datasets/{dataset_id}")
async def delete_dataset(dataset_id: str, request: Request) -> JSONResponse:
await _capture(request)
_datasets.pop(dataset_id, None)
return JSONResponse({"code": 0, "data": True})
@app.post("/api/v1/datasets/{dataset_id}/documents")
async def upload_document(
dataset_id: str,
request: Request,
file: Annotated[UploadFile, File()],
) -> JSONResponse:
content = await file.read()
body: dict[str, Any] = {
"filename": file.filename,
"size": len(content),
"sha256": hashlib.sha256(content).hexdigest(),
}
await _capture(request, body)
identifier = "doc-contract-" + str(body["sha256"])[:12]
value = {
"id": identifier,
"dataset_id": dataset_id,
"name": file.filename,
"size": len(content),
"run": "UNSTART",
"progress": 0.0,
"chunk_count": 0,
"token_count": 0,
}
_documents[identifier] = value
return JSONResponse({"code": 0, "data": [value]})
@app.get("/api/v1/datasets/{dataset_id}/documents")
async def list_documents(dataset_id: str, request: Request) -> JSONResponse:
await _capture(request)
document_id = request.query_params.get("id")
values = [value for value in _documents.values() if value["dataset_id"] == dataset_id]
if document_id:
values = [value for value in values if value["id"] == document_id]
return JSONResponse({"code": 0, "data": {"docs": values, "total": len(values)}})
@app.post("/api/v1/datasets/{dataset_id}/chunks")
async def parse_documents(dataset_id: str, request: Request) -> JSONResponse:
body = await request.json()
await _capture(request, body)
for document_id in body.get("document_ids", []):
if document_id in _documents:
_documents[document_id].update(run="DONE", progress=1.0, chunk_count=2, token_count=17)
return JSONResponse({"code": 0, "data": True})
@app.delete("/api/v1/datasets/{dataset_id}/documents")
async def delete_documents(dataset_id: str, request: Request) -> JSONResponse:
body = await request.json()
await _capture(request, body)
for document_id in body.get("ids", body.get("document_ids", [])):
_documents.pop(document_id, None)
return JSONResponse({"code": 0, "data": True})
@app.post("/voice-clone")
async def clone_voice(request: Request) -> JSONResponse:
content = await request.body()
await _capture(request, {"size": len(content), "sha256": hashlib.sha256(content).hexdigest()})
return JSONResponse({"voice_id": "voice-contract", "status": "success"})
@app.post("/transient/{failures}")
async def transient(failures: int, request: Request) -> JSONResponse:
await _capture(request)
count = sum(1 for item in _requests if item["path"] == request.url.path)
if count <= failures:
await asyncio.sleep(0)
return JSONResponse({"error": "retryable"}, status_code=503)
return JSONResponse({"ok": True})
@app.get("/binary")
async def binary(request: Request) -> JSONResponse:
await _capture(request)
content = b"contract-binary\x00\xff"
return JSONResponse({"base64": base64.b64encode(content).decode(), "size": len(content)})
@@ -0,0 +1,217 @@
from __future__ import annotations
import argparse
import asyncio
import json
import math
import time
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import httpx
from tests.compatibility.seed_contract_data import ADMIN_TOKEN, NORMAL_TOKEN, SERVER_SECRET
@dataclass(frozen=True)
class Scenario:
name: str
method: str
path: str
headers: dict[str, str]
json_body: dict[str, Any] | None = None
raw_ota: bool = False
@dataclass
class Measurement:
service: str
scenario: str
requests: int
concurrency: int
warmup_requests: int
errors: int
elapsed_seconds: float
throughput_requests_per_second: float
latency_ms_min: float
latency_ms_p50: float
latency_ms_p95: float
latency_ms_max: float
def _bearer(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
SCENARIOS = (
Scenario("representative-read", "GET", "/user/info", _bearer(ADMIN_TOKEN)),
Scenario(
"representative-crud-update",
"PUT",
"/device/update/contract-device-1",
_bearer(NORMAL_TOKEN),
{"autoUpdate": 1, "alias": None},
),
Scenario("runtime-configuration", "POST", "/config/server-base", _bearer(SERVER_SECRET)),
Scenario(
"ota-check-and-signing",
"POST",
"/ota/",
{"Device-Id": "AA:BB:CC:DD:EE:01", "Client-Id": "contract-performance"},
{"application": {"version": "1.2.3"}, "board": {"type": "esp32s3"}},
True,
),
)
def _percentile(values: list[float], percentile: float) -> float:
ordered = sorted(values)
if not ordered:
return 0.0
position = (len(ordered) - 1) * percentile
lower, upper = math.floor(position), math.ceil(position)
if lower == upper:
return ordered[lower]
return ordered[lower] + (ordered[upper] - ordered[lower]) * (position - lower)
async def _request_once(client: httpx.AsyncClient, base: str, scenario: Scenario) -> tuple[float, bool]:
started = time.perf_counter()
try:
response = await client.request(
scenario.method,
base + scenario.path,
headers=scenario.headers,
json=scenario.json_body,
)
valid = response.status_code == 200
if valid:
payload = response.json()
valid = (
isinstance(payload, dict)
and ("server_time" in payload if scenario.raw_ota else payload.get("code") == 0)
)
except (httpx.HTTPError, json.JSONDecodeError):
valid = False
return (time.perf_counter() - started) * 1000, valid
async def measure(
service: str,
base: str,
scenario: Scenario,
*,
requests: int,
concurrency: int,
warmup: int,
) -> Measurement:
limits = httpx.Limits(max_connections=concurrency, max_keepalive_connections=concurrency)
timeout = httpx.Timeout(30.0)
async with httpx.AsyncClient(limits=limits, timeout=timeout) as client:
for _ in range(warmup):
_, valid = await _request_once(client, base, scenario)
if not valid:
raise RuntimeError(f"warmup failed for {service}/{scenario.name}")
semaphore = asyncio.Semaphore(concurrency)
async def bounded_request() -> tuple[float, bool]:
async with semaphore:
return await _request_once(client, base, scenario)
started = time.perf_counter()
values = await asyncio.gather(*(bounded_request() for _ in range(requests)))
elapsed = time.perf_counter() - started
latencies = [latency for latency, _ in values]
errors = sum(not valid for _, valid in values)
return Measurement(
service=service,
scenario=scenario.name,
requests=requests,
concurrency=concurrency,
warmup_requests=warmup,
errors=errors,
elapsed_seconds=round(elapsed, 6),
throughput_requests_per_second=round(requests / elapsed, 3),
latency_ms_min=round(min(latencies), 3),
latency_ms_p50=round(_percentile(latencies, 0.50), 3),
latency_ms_p95=round(_percentile(latencies, 0.95), 3),
latency_ms_max=round(max(latencies), 3),
)
async def run(args: argparse.Namespace) -> dict[str, Any]:
services = {
"java": args.java_base.rstrip("/"),
"fastapi": args.fastapi_base.rstrip("/"),
}
results: list[Measurement] = []
for scenario in SCENARIOS:
for service, base in services.items():
results.append(
await measure(
service,
base,
scenario,
requests=args.requests,
concurrency=args.concurrency,
warmup=args.warmup,
)
)
comparisons: list[dict[str, Any]] = []
for scenario in SCENARIOS:
java = next(value for value in results if value.service == "java" and value.scenario == scenario.name)
fastapi = next(value for value in results if value.service == "fastapi" and value.scenario == scenario.name)
comparisons.append(
{
"scenario": scenario.name,
"p50_ratio_fastapi_over_java": round(fastapi.latency_ms_p50 / java.latency_ms_p50, 3),
"p95_ratio_fastapi_over_java": round(fastapi.latency_ms_p95 / java.latency_ms_p95, 3),
"throughput_ratio_fastapi_over_java": round(
fastapi.throughput_requests_per_second / java.throughput_requests_per_second,
3,
),
}
)
return {
"schema_version": 1,
"generated_at": datetime.now(tz=timezone.utc).isoformat(),
"parameters": {
"requests_per_service_per_scenario": args.requests,
"concurrency": args.concurrency,
"sequential_warmup_requests": args.warmup,
"scenario_order": [scenario.name for scenario in SCENARIOS],
"service_order": ["java", "fastapi"],
},
"results": [asdict(result) for result in results],
"comparisons": comparisons,
"summary": {
"measurements": len(results),
"requests_measured": sum(result.requests for result in results),
"errors": sum(result.errors for result in results),
},
}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--java-base", default="http://127.0.0.1:18082/xiaozhi")
parser.add_argument("--fastapi-base", default="http://127.0.0.1:18083/xiaozhi")
parser.add_argument("--requests", type=int, default=60)
parser.add_argument("--concurrency", type=int, default=6)
parser.add_argument("--warmup", type=int, default=10)
parser.add_argument("--output", type=Path, default=Path("compatibility/performance-results.json"))
args = parser.parse_args()
if args.requests <= 0 or args.concurrency <= 0 or args.warmup < 0:
parser.error("requests/concurrency must be positive and warmup must be non-negative")
report = asyncio.run(run(args))
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report["summary"], ensure_ascii=False))
if report["summary"]["errors"]:
raise SystemExit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,97 @@
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any, cast
from tests.compatibility.differential_runner import ContractRunner, _build_report
TARGET_ROOT = Path(__file__).resolve().parents[2]
JAVA_MANIFEST = TARGET_ROOT / "compatibility" / "java-routes.json"
PATH_PARAMETER = re.compile(r"\{([^/{}]+)\}")
SAFE_PATH_VALUES = {
"agentId": "contract-agent-1",
"audioId": "contract-audio",
"deviceCode": "000000",
"deviceId": "contract-device-1",
"dictType": "FIRMWARE_TYPE",
"document_id": "contract-document",
"dataset_id": "contract-dataset",
"fileId": "contract-words",
"id": "contract-id",
"macAddress": "AA:BB:CC:DD:EE:01",
"modelId": "contract-model",
"modelType": "LLM",
"provideCode": "OpenAI",
"sessionId": "contract-session",
"snapshotId": "contract-snapshot",
"status": "1",
"userId": "contract-user",
"uuid": "contract-unknown-token",
}
def _safe_path(template: str) -> str:
return PATH_PARAMETER.sub(lambda match: SAFE_PATH_VALUES.get(match.group(1), "contract-value"), template)
def _load_routes() -> list[dict[str, Any]]:
manifest = json.loads(JAVA_MANIFEST.read_text(encoding="utf-8"))
routes = cast(list[dict[str, Any]], manifest["routes"])
if manifest["count"] != 154 or len(routes) != 154:
raise RuntimeError("Java route manifest is not closed at 154 routes")
return routes
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--java-base", default="http://127.0.0.1:18082/xiaozhi")
parser.add_argument("--fastapi-base", default="http://127.0.0.1:18083/xiaozhi")
parser.add_argument("--mock-base", default="http://127.0.0.1:18084")
parser.add_argument("--mysql-port", type=int, default=13316)
parser.add_argument(
"--output",
type=Path,
default=Path("compatibility/route-surface-results.json"),
)
args = parser.parse_args()
runner = ContractRunner(args.java_base, args.fastapi_base, args.mock_base, args.mysql_port)
try:
for route in _load_routes():
method = str(route["method"])
template = str(route["path"])
runner.request_pair(
f"{method} {template}",
f"route-surface:{route['auth']}",
method,
_safe_path(template),
exact_headers=("content-type",),
)
finally:
runner.close()
report = _build_report(runner)
report["coverage"] = {
"java_routes": len(runner.results),
"request_profile": "missing-auth-or-safe-invalid-input",
"side_effect_policy": "no successful write request is issued",
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n",
encoding="utf-8",
)
summary = report["summary"]
print(json.dumps(summary, ensure_ascii=False))
for result in runner.results:
if not result.passed:
print(f"FAIL {result.name}: {result.difference}")
sys.exit(1 if summary["failed"] else 0)
if __name__ == "__main__":
main()
@@ -0,0 +1,286 @@
from __future__ import annotations
import argparse
import asyncio
from datetime import datetime
from typing import Any
import asyncmy # type: ignore[import-untyped]
from asyncmy.cursors import DictCursor # type: ignore[import-untyped]
ADMIN_ID = 9_007_199_254_740_993
NORMAL_ID = 9_007_199_254_740_994
EXPIRED_ID = 9_007_199_254_740_995
ADMIN_TOKEN = "contract-admin-token" # noqa: S105 - isolated fixture credential
NORMAL_TOKEN = "contract-normal-token" # noqa: S105 - isolated fixture credential
EXPIRED_TOKEN = "contract-expired-token" # noqa: S105 - isolated fixture credential
SERVER_SECRET = "contract-server-secret" # noqa: S105 - isolated fixture credential
MQTT_SIGNATURE_KEY = "contract-mqtt-signature-key"
MIGRATION_TIMESTAMP_TABLES = (
"ai_model_provider",
"sys_dict_data",
"sys_dict_type",
)
def _database_config(port: int, database: str) -> dict[str, Any]:
return {
"host": "127.0.0.1",
"port": port,
"user": "xiaozhi_test",
"password": "isolated-test-only",
"db": database,
"charset": "utf8mb4",
"autocommit": False,
}
async def _query_params(port: int, database: str, codes: tuple[str, ...]) -> dict[str, str | None]:
connection = await asyncmy.connect(**_database_config(port, database))
try:
async with connection.cursor(DictCursor) as cursor:
placeholders = ",".join(["%s"] * len(codes))
await cursor.execute(
f"SELECT param_code,param_value FROM sys_params WHERE param_code IN ({placeholders})", # noqa: S608
codes,
)
rows = await cursor.fetchall()
return {str(row["param_code"]): row["param_value"] for row in rows}
finally:
connection.close()
async def _seed_database(
port: int,
database: str,
crypto_params: dict[str, str | None],
mock_port: int,
) -> None:
connection = await asyncmy.connect(**_database_config(port, database))
now = datetime(2026, 7, 20, 12, 34, 56)
try:
async with connection.cursor(DictCursor) as cursor:
await cursor.execute(
"SELECT id FROM ai_agent_correct_word_file WHERE file_name LIKE 'contract-run-%'"
)
generated_file_ids = [str(row["id"]) for row in await cursor.fetchall()]
if generated_file_ids:
placeholders = ",".join(["%s"] * len(generated_file_ids))
await cursor.execute(
f"DELETE FROM ai_agent_correct_word_mapping WHERE file_id IN ({placeholders})", # noqa: S608
generated_file_ids,
)
await cursor.execute(
f"DELETE FROM ai_agent_correct_word_item WHERE file_id IN ({placeholders})", # noqa: S608
generated_file_ids,
)
await cursor.execute(
f"DELETE FROM ai_agent_correct_word_file WHERE id IN ({placeholders})", # noqa: S608
generated_file_ids,
)
await cursor.execute("DELETE FROM ai_ota WHERE id='contract-ota-run'")
for identifier, username, super_admin in (
(ADMIN_ID, "contract-admin", 1),
(NORMAL_ID, "contract-user", 0),
(EXPIRED_ID, "contract-expired", 0),
):
await cursor.execute(
"INSERT INTO sys_user(id,username,password,super_admin,status,create_date,updater,creator,"
"update_date) "
"VALUES(%s,%s,%s,%s,1,%s,%s,%s,%s) AS fixture ON DUPLICATE KEY UPDATE "
"username=fixture.username,password=fixture.password,super_admin=fixture.super_admin,status=1,"
"create_date=fixture.create_date,updater=fixture.updater,creator=fixture.creator,"
"update_date=fixture.update_date",
(
identifier,
username,
"$2a$10$contract.fixture.not.used",
super_admin,
now,
ADMIN_ID,
ADMIN_ID,
now,
),
)
for identifier, user_id, token, expires in (
(ADMIN_ID, ADMIN_ID, ADMIN_TOKEN, datetime(2099, 1, 1)),
(NORMAL_ID, NORMAL_ID, NORMAL_TOKEN, datetime(2099, 1, 1)),
(EXPIRED_ID, EXPIRED_ID, EXPIRED_TOKEN, datetime(2020, 1, 1)),
):
await cursor.execute(
"INSERT INTO sys_user_token(id,user_id,token,expire_date,update_date,create_date) "
"VALUES(%s,%s,%s,%s,%s,%s) AS fixture ON DUPLICATE KEY UPDATE token=fixture.token,"
"expire_date=fixture.expire_date,update_date=fixture.update_date,create_date=fixture.create_date",
(identifier, user_id, token, expires, now, now),
)
parameter_values: dict[str, str | None] = {
**crypto_params,
"server.secret": SERVER_SECRET,
"server.auth.enabled": "true",
"server.websocket": "ws://127.0.0.1:18080/xiaozhi/v1/",
"server.mqtt_gateway": "mqtt://127.0.0.1:1883",
"server.mqtt_manager_api": f"127.0.0.1:{mock_port}",
"server.mqtt_signature_key": MQTT_SIGNATURE_KEY,
"server.ota": "http://127.0.0.1:18082/xiaozhi/ota/",
"server.frontend.url": "http://127.0.0.1:8001",
}
for code, value in parameter_values.items():
await cursor.execute(
"UPDATE sys_params SET param_value=%s,update_date=%s WHERE param_code=%s",
(value, now, code),
)
await cursor.execute(
"INSERT INTO ai_agent(id,user_id,agent_code,agent_name,system_prompt,summary_memory,chat_history_conf,"
"lang_code,language,sort,creator,created_at,updater,updated_at) "
"VALUES(%s,%s,%s,%s,NULL,%s,1,%s,%s,7,%s,%s,%s,%s) AS fixture ON DUPLICATE KEY UPDATE "
"user_id=fixture.user_id,agent_code=fixture.agent_code,agent_name=fixture.agent_name,"
"system_prompt=NULL,summary_memory=fixture.summary_memory,chat_history_conf=1,"
"lang_code=fixture.lang_code,language=fixture.language,sort=7,creator=fixture.creator,"
"created_at=fixture.created_at,updater=fixture.updater,updated_at=fixture.updated_at",
(
"contract-agent-1",
NORMAL_ID,
"contract-agent-code",
"Contract Agent",
"fixture memory",
"zh-CN",
"zh",
NORMAL_ID,
now,
NORMAL_ID,
now,
),
)
await cursor.execute(
"INSERT INTO ai_device(id,user_id,mac_address,last_connected_at,auto_update,board,alias,agent_id,"
"app_version,sort,creator,create_date,updater,update_date) "
"VALUES(%s,%s,%s,%s,1,%s,NULL,%s,%s,3,%s,%s,%s,%s) "
"AS fixture ON DUPLICATE KEY UPDATE user_id=fixture.user_id,mac_address=fixture.mac_address,"
"last_connected_at=fixture.last_connected_at,auto_update=1,board=fixture.board,alias=NULL,"
"agent_id=fixture.agent_id,app_version=fixture.app_version,sort=3,creator=fixture.creator,"
"create_date=fixture.create_date,updater=fixture.updater,update_date=fixture.update_date",
(
"contract-device-1",
NORMAL_ID,
"AA:BB:CC:DD:EE:01",
now,
"esp32s3",
"contract-agent-1",
"1.2.3",
NORMAL_ID,
now,
NORMAL_ID,
now,
),
)
await cursor.execute(
"INSERT INTO ai_model_provider(id,model_type,provider_code,name,fields,sort,creator,create_date,"
"updater,update_date) "
"VALUES(%s,%s,%s,%s,CAST(%s AS JSON),9,%s,%s,%s,%s) AS fixture ON DUPLICATE KEY UPDATE "
"model_type=fixture.model_type,provider_code=fixture.provider_code,name=fixture.name,"
"fields=fixture.fields,sort=9,creator=fixture.creator,create_date=fixture.create_date,"
"updater=fixture.updater,update_date=fixture.update_date",
(
"contract-provider",
"LLM",
"contract",
"Contract Provider",
'[{"key":"api_key","label":"API Key","type":"string"}]',
ADMIN_ID,
now,
ADMIN_ID,
now,
),
)
await cursor.execute(
"INSERT INTO ai_agent_correct_word_file(id,file_name,word_count,content,creator,created_at,updater,"
"updated_at) "
"VALUES(%s,%s,2,%s,%s,%s,%s,%s) AS fixture ON DUPLICATE KEY UPDATE "
"file_name=fixture.file_name,word_count=2,content=fixture.content,creator=fixture.creator,"
"created_at=fixture.created_at,updater=fixture.updater,updated_at=fixture.updated_at",
(
"contract-words",
"合同词表.txt",
"小智,小志\nESP32,esp32",
NORMAL_ID,
now,
NORMAL_ID,
now,
),
)
await cursor.execute("DELETE FROM ai_agent_correct_word_item WHERE file_id=%s", ("contract-words",))
for word_id, source, target in (
("contract-word-1", "小智", "小志"),
("contract-word-2", "ESP32", "esp32"),
):
await cursor.execute(
"INSERT INTO ai_agent_correct_word_item(id,file_id,source_word,target_word) VALUES(%s,%s,%s,%s)",
(word_id, "contract-words", source, target),
)
await cursor.execute(
"INSERT INTO ai_agent_correct_word_mapping(id,agent_id,file_id,creator,created_at,updater,updated_at) "
"VALUES(%s,%s,%s,%s,%s,%s,%s) AS fixture ON DUPLICATE KEY UPDATE creator=fixture.creator,"
"created_at=fixture.created_at,updater=fixture.updater,updated_at=fixture.updated_at",
("contract-word-map", "contract-agent-1", "contract-words", NORMAL_ID, now, NORMAL_ID, now),
)
await connection.commit()
except Exception:
await connection.rollback()
raise
finally:
connection.close()
async def _sync_migration_timestamps(port: int) -> None:
"""Copy baseline-generated timestamps for rows shared by both schemas.
Several Liquibase seed changesets use the database current timestamp. The
Java and FastAPI schemas are migrated a few seconds apart, which would make
otherwise identical read responses differ. The isolated contract fixture
treats Java as the behavior baseline and aligns only the audit timestamps of
matching migration rows; it does not copy business data or change IDs.
"""
connection = await asyncmy.connect(**_database_config(port, "manager_fastapi_test"))
try:
async with connection.cursor(DictCursor) as cursor:
for table in MIGRATION_TIMESTAMP_TABLES:
await cursor.execute(
f"UPDATE manager_fastapi_test.`{table}` AS target " # noqa: S608
f"JOIN manager_java_test.`{table}` AS baseline ON baseline.id=target.id "
"SET target.create_date=baseline.create_date,target.update_date=baseline.update_date"
)
await connection.commit()
except Exception:
await connection.rollback()
raise
finally:
connection.close()
async def seed(port: int, mock_port: int) -> None:
crypto = await _query_params(
port,
"manager_java_test",
("server.public_key", "server.private_key"),
)
if not crypto.get("server.public_key") or not crypto.get("server.private_key"):
raise RuntimeError("Java baseline must be started once before seeding so its SM2 keypair exists")
for database in ("manager_java_test", "manager_fastapi_test"):
await _seed_database(port, database, crypto, mock_port)
await _sync_migration_timestamps(port)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--mysql-port", type=int, default=13316)
parser.add_argument("--mock-port", type=int, default=18084)
args = parser.parse_args()
asyncio.run(seed(args.mysql_port, args.mock_port))
if __name__ == "__main__":
main()
@@ -0,0 +1,70 @@
from __future__ import annotations
import argparse
import asyncio
import contextlib
async def _copy(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
while data := await reader.read(64 * 1024):
writer.write(data)
await writer.drain()
finally:
with contextlib.suppress(Exception):
writer.write_eof()
async def _handle(
downstream_reader: asyncio.StreamReader,
downstream_writer: asyncio.StreamWriter,
target_host: str,
target_port: int,
) -> None:
try:
upstream_reader, upstream_writer = await asyncio.open_connection(target_host, target_port)
except OSError:
downstream_writer.close()
await downstream_writer.wait_closed()
return
try:
tasks = {
asyncio.create_task(_copy(downstream_reader, upstream_writer)),
asyncio.create_task(_copy(upstream_reader, downstream_writer)),
}
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
await asyncio.gather(*done, *pending, return_exceptions=True)
finally:
upstream_writer.close()
downstream_writer.close()
await asyncio.gather(
upstream_writer.wait_closed(),
downstream_writer.wait_closed(),
return_exceptions=True,
)
async def _serve(listen_port: int, target_host: str, target_port: int) -> None:
server = await asyncio.start_server(
lambda reader, writer: _handle(reader, writer, target_host, target_port),
host="0.0.0.0", # noqa: S104 - isolated container-to-host test bridge
port=listen_port,
)
async with server:
await server.serve_forever()
def main() -> None:
parser = argparse.ArgumentParser(description="Ephemeral TCP bridge for isolated container tests")
parser.add_argument("--listen-port", type=int, required=True)
parser.add_argument("--target-host", default="127.0.0.1")
parser.add_argument("--target-port", type=int, required=True)
args = parser.parse_args()
asyncio.run(_serve(args.listen_port, args.target_host, args.target_port))
if __name__ == "__main__":
main()
@@ -0,0 +1,134 @@
from __future__ import annotations
import json
from tests.compatibility.authenticated_route_runner import (
DYNAMIC_RESPONSES,
JAVA_MANIFEST,
MULTIPART_ROUTES,
NO_BODY_ROUTES,
OTA_DOWNLOAD_UUID_ROUTE,
RANDOM_PASSWORD_ROUTE,
SINGLE_CONSTRAINT_PAYLOADS,
_normalize_ota_download_uuid,
_normalize_random_password,
_valid_random_password,
_valid_uuid4,
_validate_dynamic_response,
build_cases,
)
from tests.compatibility.differential_runner import ContractRunner
from tests.compatibility.seed_contract_data import (
ADMIN_TOKEN,
MIGRATION_TIMESTAMP_TABLES,
SERVER_SECRET,
)
def test_authenticated_route_cases_are_fresh_and_close_all_java_routes() -> None:
manifest = json.loads(JAVA_MANIFEST.read_text(encoding="utf-8"))
expected = {(str(route["method"]), str(route["path"])) for route in manifest["routes"]}
cases = build_cases()
assert manifest["count"] == len(expected) == len(cases) == 154
assert {case.key for case in cases} == expected
assert len({(case.method, case.path) for case in cases}) == 154
def test_authenticated_route_cases_use_safe_auth_and_body_profiles() -> None:
for case in build_cases():
headers = case.request_kwargs.get("headers", {})
if case.auth == "database-token":
assert headers == {"Authorization": f"Bearer {ADMIN_TOKEN}"}
elif case.auth == "server-secret":
assert headers == {"Authorization": f"Bearer {SERVER_SECRET}"}
else:
assert headers == {}
if case.method in {"GET", "DELETE"}:
assert "json" not in case.request_kwargs
elif case.key in MULTIPART_ROUTES | NO_BODY_ROUTES:
assert "json" not in case.request_kwargs
elif case.key in SINGLE_CONSTRAINT_PAYLOADS:
assert case.request_kwargs.get("json") == SINGLE_CONSTRAINT_PAYLOADS[case.key]
else:
assert case.request_kwargs.get("json") == {}
def test_array_body_validation_routes_are_not_hidden_by_safe_case_overrides() -> None:
cases = {case.key: case for case in build_cases()}
object_sent_to_array_routes = {
("POST", "/admin/dict/data/delete"),
("POST", "/admin/dict/type/delete"),
("POST", "/admin/params/delete"),
("PUT", "/admin/users/changeStatus/{status}"),
("POST", "/agent/template/batch-remove"),
("POST", "/correct-word/file/batch-delete"),
("POST", "/models/provider/delete"),
("POST", "/ttsVoice/delete"),
}
for key in object_sent_to_array_routes:
assert cases[key].request_kwargs.get("json") == {}
assert "json" not in cases[("DELETE", "/datasets/batch")].request_kwargs
def test_dynamic_response_normalizers_require_the_full_java_formats() -> None:
valid_password = "aA1!bcDEF234" # noqa: S105 - format-only fixture value
assert _valid_random_password(valid_password)
assert not _valid_random_password("aA1!short")
assert not _valid_random_password("aA1_bcDEF234")
assert not _valid_random_password("abcdefghijkl")
valid_uuid = "123e4567-e89b-42d3-a456-426614174000"
assert _valid_uuid4(valid_uuid)
assert not _valid_uuid4("123e4567-e89b-12d3-a456-426614174000")
assert not _valid_uuid4(valid_uuid.upper())
password_body = {"code": 0, "msg": "success", "data": valid_password}
uuid_body = {"code": 0, "msg": "success", "data": valid_uuid}
assert _normalize_random_password(password_body) == {
"code": 0,
"msg": "success",
"data": "<valid-random-password>",
}
assert _normalize_ota_download_uuid(uuid_body) == {
"code": 0,
"msg": "success",
"data": "<valid-uuid-v4>",
}
invalid = {"code": 0, "msg": "success", "data": "not-dynamic"}
assert _normalize_random_password(invalid) is invalid
assert _normalize_ota_download_uuid(invalid) is invalid
assert set(DYNAMIC_RESPONSES) == {RANDOM_PASSWORD_ROUTE, OTA_DOWNLOAD_UUID_ROUTE}
def test_identical_invalid_dynamic_values_fail_both_independent_format_checks() -> None:
runner = ContractRunner("http://java.invalid", "http://fastapi.invalid", "http://mock.invalid", 0)
try:
runner.add_custom("dynamic", "test", {"body": True})
invalid = {"code": 0, "msg": "success", "data": "same-invalid-value"}
_validate_dynamic_response(
runner,
java_body=invalid,
fastapi_body=invalid,
validator=_valid_uuid4,
label="uuid_v4",
)
result = runner.results[-1]
assert not result.passed
assert result.checks["body"]
assert not result.checks["java:uuid_v4"]
assert not result.checks["fastapi:uuid_v4"]
assert result.difference == (
"invalid dynamic response format: java:uuid_v4, fastapi:uuid_v4"
)
finally:
runner.close()
def test_migration_timestamp_fixture_is_limited_to_generated_seed_rows() -> None:
assert MIGRATION_TIMESTAMP_TABLES == (
"ai_model_provider",
"sys_dict_data",
"sys_dict_type",
)
@@ -0,0 +1,56 @@
from __future__ import annotations
from tests.compatibility.differential_runner import (
CapturedResponse,
_redact_report_value,
_response_summary,
)
def test_recursive_report_redaction_preserves_non_sensitive_shape() -> None:
source = {
"data": {
"private_key": "generated-private-key",
"server.secret": "fixture-secret",
"mqtt_signature_key": "fixture-signature",
"token": "fixture-token",
"token_count": 17,
"public_key": "public-material",
"params": [
{"paramCode": "server.secret", "paramValue": "fixture-param-secret"},
{"paramCode": "server.websocket", "paramValue": "ws://safe.example/ws"},
],
},
"headers": {"authorization": "Bearer fixture"},
}
assert _redact_report_value(source) == {
"data": {
"private_key": "<redacted>",
"server.secret": "<redacted>",
"mqtt_signature_key": "<redacted>",
"token": "<redacted>",
"token_count": 17,
"public_key": "public-material",
"params": [
{"paramCode": "server.secret", "paramValue": "<redacted>"},
{"paramCode": "server.websocket", "paramValue": "ws://safe.example/ws"},
],
},
"headers": {"authorization": "<redacted>"},
}
def test_response_summary_never_embeds_sensitive_values_in_body_or_excerpt() -> None:
response = CapturedResponse(
status=200,
headers={"content-type": "application/json", "content-disposition": None, "content-length": None},
body={"token": "fixture-token", "password": "fixture-password"},
body_sha256="raw-response-digest",
)
summary = _response_summary(response, response.body)
assert summary["body"] == {"token": "<redacted>", "password": "<redacted>"}
assert "fixture-token" not in summary["body_excerpt"]
assert "fixture-password" not in summary["body_excerpt"]