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 @@
"""Manager API FastAPI automated tests."""
@@ -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"]
@@ -0,0 +1,110 @@
from __future__ import annotations
import time
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
class FakeRedis:
def __init__(self) -> None:
self.values: dict[str, Any] = {}
self.hashes: dict[str, dict[str, Any]] = {}
self.expirations: dict[str, float] = {}
def _purge(self, key: str) -> None:
expiry = self.expirations.get(key)
if expiry is not None and expiry <= time.monotonic():
self.values.pop(key, None)
self.hashes.pop(key, None)
self.expirations.pop(key, None)
async def get(self, key: str) -> Any:
self._purge(key)
return self.values.get(key)
async def set(
self,
key: str,
value: Any,
*,
ex: int | None = None,
nx: bool = False,
) -> bool | None:
self._purge(key)
if nx and key in self.values:
return None
self.values[key] = value
if ex is not None:
self.expirations[key] = time.monotonic() + ex
return True
async def delete(self, *keys: str) -> int:
deleted = 0
for key in keys:
deleted += int(key in self.values or key in self.hashes)
self.values.pop(key, None)
self.hashes.pop(key, None)
self.expirations.pop(key, None)
return deleted
async def incr(self, key: str) -> int:
self._purge(key)
raw = self.values.get(key, b"0")
if isinstance(raw, bytes):
raw = raw.decode()
value = int(raw) + 1
self.values[key] = str(value).encode()
return value
async def expire(self, key: str, seconds: int) -> bool:
if key not in self.values and key not in self.hashes:
return False
self.expirations[key] = time.monotonic() + seconds
return True
async def ttl(self, key: str) -> int:
self._purge(key)
if key not in self.values and key not in self.hashes:
return -2
expiry = self.expirations.get(key)
return -1 if expiry is None else max(0, int(expiry - time.monotonic()))
async def hget(self, key: str, field: str) -> Any:
self._purge(key)
return self.hashes.get(key, {}).get(field)
async def hset(self, key: str, field: str, value: Any) -> int:
created = field not in self.hashes.setdefault(key, {})
self.hashes[key][field] = value
return int(created)
async def hdel(self, key: str, *fields: str) -> int:
values = self.hashes.get(key, {})
deleted = 0
for field in fields:
deleted += int(field in values)
values.pop(field, None)
return deleted
@asynccontextmanager
async def sqlite_session(statements: list[str]) -> AsyncIterator[AsyncSession]:
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as connection:
for statement in statements:
await connection.execute(text(statement))
factory = async_sessionmaker(engine, expire_on_commit=False)
try:
async with factory() as session:
yield session
finally:
await engine.dispose()
@@ -0,0 +1,17 @@
[
{
"agentId": "0123456789abcdef0123456789abcdef",
"key": "0123456789abcdef",
"token": "gGoKMxPZmy7exM0UfEVukLNhJhqNrpQqZp3dFvGxSP9A/OF5dw4QOxYI+5vaDP9k"
},
{
"agentId": "agent-测试-0001",
"key": "short-key",
"token": "J2iqQ9MTPgcWyMBBnukLWoCoepQ3iL3ifRep9U9wHL404qPjkQbaki4gx+GF3Zju"
},
{
"agentId": "ffffffffffffffffffffffffffffffff",
"key": "0123456789abcdef0123456789abcdef-extra",
"token": "aX8khOtYV5n7m6cJdkhIpC0crHYfz1FWAsD4HmCNBNfTlIy7qU1vVLOZBdNonA6r"
}
]
@@ -0,0 +1,8 @@
{
"algorithm": "SM2 C1C3C2 / sm2p256v1",
"publicKey": "04725b2e5df390575296c8753d883a88680a879152df47106e30156aba4ebfd354d77e1503edfaf7566ae26a34dfc40b4758f72aa850d82df0d8bfd29319f7419a",
"privateKey": "4a5a013f676be5ee700588593e50960470c9469cc9ebadade37149bc5a440228",
"plaintext": "A1b2C小智-2026",
"pythonCiphertext": "042f22fabd89b2012aff7141284850db1155c68f384237ea317ece255247f48014e12a4e48216ba9765a511a2548940125554b201d0828e0c676b74158e44488ebc0920e858c513661a2184eb64ac19e729d2e3684e78b3a267da0bd3a7886b060238a210683ad296cb5bb0cef29463ce8",
"javaCiphertext": "04c09af2b7c3bc9dad2610f7e22c166dad403e84d0a337033e0287bdc7fae5c6535055f34ecf908035df567c2fa7ed4cdb56046e277bb5f0c8782d2efbe789a00462c564788025b2de83bc07483dd46e83693d2944d3a28c189476ff2f64ebbcc8d4f8640f258a780e301e749a4f438964"
}
@@ -0,0 +1 @@
"""Tests requiring the repository's isolated MySQL and Redis runtime."""
@@ -0,0 +1,254 @@
from __future__ import annotations
import asyncio
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any, cast
import pytest
from redis.asyncio import Redis
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from app.core.redis import close_redis, distributed_lock
pytestmark = pytest.mark.integration
def _required_environment(name: str) -> str:
value = os.getenv(name)
if not value:
pytest.skip(f"{name} is required; use scripts/isolated-env.sh env")
return value
@pytest.fixture
async def engine() -> AsyncIterator[AsyncEngine]:
value = _required_environment("TEST_FASTAPI_DATABASE_URL")
selected = create_async_engine(value, pool_pre_ping=True)
try:
yield selected
finally:
await selected.dispose()
@pytest.fixture
async def isolated_redis() -> AsyncIterator[Redis]:
value = _required_environment("TEST_FASTAPI_REDIS_URL")
selected = Redis.from_url(value, decode_responses=True)
try:
if not await selected.ping():
pytest.fail("isolated Redis did not answer PING")
yield selected
finally:
await selected.delete(
"contract:test:ttl",
"contract:test:persistent-sentinel",
"jobs:knowledge-document-status",
"jobs:contract-renewal",
)
await selected.aclose()
@pytest.mark.asyncio
async def test_mysql_transaction_rollback(engine: AsyncEngine) -> None:
parameter_id = 9_007_199_254_740_980
code = "contract.test.rollback"
async with engine.begin() as connection:
await connection.execute(text("DELETE FROM sys_params WHERE param_code=:code"), {"code": code})
with pytest.raises(RuntimeError, match="force rollback"):
async with engine.begin() as connection:
await connection.execute(
text(
"INSERT INTO sys_params(id,param_code,param_value,value_type,param_type) "
"VALUES(:id,:code,'before-rollback','string',1)"
),
{"id": parameter_id, "code": code},
)
raise RuntimeError("force rollback")
async with engine.connect() as connection:
count = await connection.scalar(
text("SELECT COUNT(*) FROM sys_params WHERE param_code=:code"),
{"code": code},
)
assert count == 0
@pytest.mark.asyncio
async def test_mysql_select_for_update_serializes_concurrent_writers(engine: AsyncEngine) -> None:
parameter_id = 9_007_199_254_740_981
code = "contract.test.for-update"
async with engine.begin() as connection:
await connection.execute(text("DELETE FROM sys_params WHERE param_code=:code"), {"code": code})
await connection.execute(
text(
"INSERT INTO sys_params(id,param_code,param_value,value_type,param_type) "
"VALUES(:id,:code,'0','number',1)"
),
{"id": parameter_id, "code": code},
)
first_has_lock = asyncio.Event()
async def increment(*, hold_lock: bool) -> None:
async with engine.begin() as connection:
result = await connection.execute(
text("SELECT param_value FROM sys_params WHERE param_code=:code FOR UPDATE"),
{"code": code},
)
current = int(result.scalar_one())
if hold_lock:
first_has_lock.set()
await asyncio.sleep(0.2)
await connection.execute(
text("UPDATE sys_params SET param_value=:value WHERE param_code=:code"),
{"value": str(current + 1), "code": code},
)
first = asyncio.create_task(increment(hold_lock=True))
await first_has_lock.wait()
second = asyncio.create_task(increment(hold_lock=False))
await asyncio.gather(first, second)
async with engine.connect() as connection:
value = await connection.scalar(
text("SELECT param_value FROM sys_params WHERE param_code=:code"),
{"code": code},
)
assert value == "2"
async with engine.begin() as connection:
await connection.execute(text("DELETE FROM sys_params WHERE param_code=:code"), {"code": code})
@pytest.mark.asyncio
async def test_redis_ttl_expires_without_clearing_unrelated_keys(isolated_redis: Redis) -> None:
await isolated_redis.set("contract:test:persistent-sentinel", "preserved")
await isolated_redis.set("contract:test:ttl", "temporary", ex=1)
ttl = await isolated_redis.ttl("contract:test:ttl")
assert 0 < ttl <= 1
await asyncio.sleep(1.1)
assert await isolated_redis.get("contract:test:ttl") is None
assert await isolated_redis.get("contract:test:persistent-sentinel") == "preserved"
@pytest.mark.asyncio
async def test_job_distributed_lock_allows_one_concurrent_execution(
isolated_redis: Redis,
) -> None:
await isolated_redis.delete("jobs:knowledge-document-status")
executions = 0
entered = asyncio.Event()
async def contender() -> bool:
nonlocal executions
async with distributed_lock("jobs:knowledge-document-status", 10) as acquired:
if not acquired:
return False
executions += 1
entered.set()
await asyncio.sleep(0.2)
return True
try:
first = asyncio.create_task(contender())
await asyncio.wait_for(entered.wait(), timeout=2)
second = asyncio.create_task(contender())
results = await asyncio.gather(first, second)
assert list(results) == [True, False]
assert executions == 1
assert await isolated_redis.exists("jobs:knowledge-document-status") == 0
finally:
await close_redis()
@pytest.mark.asyncio
async def test_job_distributed_lock_watchdog_renews_long_execution(
isolated_redis: Redis,
) -> None:
"""A job longer than its initial lease must remain single-instance."""
key = "jobs:contract-renewal"
await isolated_redis.delete(key)
first_entered = asyncio.Event()
release_first = asyncio.Event()
async def first_owner() -> bool:
async with distributed_lock(key, 1) as acquired:
assert acquired
first_entered.set()
await release_first.wait()
return acquired
first = asyncio.create_task(first_owner())
try:
await asyncio.wait_for(first_entered.wait(), timeout=2)
# Cross the original one-second lease. The watchdog should have extended
# it, so another worker still cannot enter.
await asyncio.sleep(1.25)
async with distributed_lock(key, 1) as second_acquired:
assert not second_acquired
release_first.set()
assert await first
assert await isolated_redis.exists(key) == 0
finally:
release_first.set()
if not first.done():
await first
await close_redis()
class _FakeFactory:
@asynccontextmanager
async def __call__(self) -> AsyncIterator[object]:
yield object()
@pytest.mark.asyncio
async def test_knowledge_job_function_is_single_instance(
isolated_redis: Redis,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.jobs import tasks
from app.services.knowledge import KnowledgeDocumentService
await isolated_redis.delete("jobs:knowledge-document-status")
executions = 0
entered = asyncio.Event()
async def fake_sync(_: KnowledgeDocumentService) -> int:
nonlocal executions
executions += 1
entered.set()
await asyncio.sleep(0.2)
return 1
monkeypatch.setattr(tasks, "get_session_factory", lambda: _FakeFactory())
monkeypatch.setattr(KnowledgeDocumentService, "sync_running", fake_sync)
try:
first = asyncio.create_task(tasks.sync_running_knowledge_documents())
await asyncio.wait_for(entered.wait(), timeout=2)
second = asyncio.create_task(tasks.sync_running_knowledge_documents())
results = await asyncio.gather(first, second)
assert list(results) == [1, 0]
assert executions == 1
finally:
await close_redis()
@pytest.mark.asyncio
async def test_redis_java_hash_default_ttl(isolated_redis: Redis) -> None:
from app.core.redis import java_hset
try:
await java_hset("contract:test:java-hash", "field", {"id": 9_007_199_254_740_993})
ttl = await isolated_redis.ttl("contract:test:java-hash")
assert 86_390 <= ttl <= 86_400
raw = await cast(Any, isolated_redis.hget)("contract:test:java-hash", "field")
assert raw is not None
await isolated_redis.delete("contract:test:java-hash")
finally:
await close_redis()
@@ -0,0 +1,19 @@
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import xiaozhi.common.utils.AESUtils;
public final class AgentMcpCryptoInteropCli {
private AgentMcpCryptoInteropCli() {
}
public static void main(String[] args) throws Exception {
if (args.length != 2) {
throw new IllegalArgumentException("usage: AgentMcpCryptoInteropCli <agent-id> <key>");
}
byte[] digest = MessageDigest.getInstance("MD5").digest(args[0].getBytes(StandardCharsets.UTF_8));
String json = "{\"agentId\": \"%s\"}".formatted(HexFormat.of().formatHex(digest));
System.out.print(AESUtils.encrypt(args[1], json));
}
}
@@ -0,0 +1,63 @@
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.data.redis.serializer.RedisSerializer;
import cn.hutool.json.JSONObject;
import xiaozhi.modules.model.entity.ModelConfigEntity;
import xiaozhi.modules.sys.vo.SysDictDataItem;
import xiaozhi.modules.timbre.vo.TimbreDetailsVO;
public final class RedisCodecVectors {
private RedisCodecVectors() {}
private static void vector(String name, Object value) {
RedisSerializer<Object> serializer = RedisSerializer.json();
byte[] bytes = serializer.serialize(value);
Object decoded = serializer.deserialize(bytes);
System.out.println(name + "\t" + new String(bytes, StandardCharsets.UTF_8)
+ "\t" + (decoded == null ? "null" : decoded.getClass().getName()));
}
public static void main(String[] args) {
vector("string", "hello");
vector("integer", Integer.valueOf(7));
vector("long", Long.valueOf(2147483648L));
vector("boolean", Boolean.TRUE);
Map<String, Object> map = new HashMap<>();
map.put("text", "hello");
map.put("number", Long.valueOf(2147483648L));
map.put("list", new ArrayList<>(List.of("a", "b")));
Map<String, Object> nested = new HashMap<>();
nested.put("enabled", Boolean.TRUE);
map.put("nested", nested);
vector("map", map);
vector("list", new ArrayList<>(List.of("a", Long.valueOf(2147483648L), nested)));
vector("date", new Date(0));
TimbreDetailsVO timbre = new TimbreDetailsVO();
timbre.setId("voice-1");
timbre.setName("sample");
timbre.setSort(2L);
vector("pojo", timbre);
ModelConfigEntity model = new ModelConfigEntity();
model.setId("model-1");
model.setModelType("LLM");
JSONObject config = new JSONObject();
config.set("type", "openai");
config.set("api_key", "secret");
model.setConfigJson(config);
vector("model-pojo", model);
SysDictDataItem item = new SysDictDataItem();
item.setName("China");
item.setKey("+86");
vector("dict-list", new ArrayList<>(List.of(item)));
}
}
@@ -0,0 +1,18 @@
import xiaozhi.common.utils.SM2Utils;
/** Tiny subprocess boundary used by the Python/Java SM2 interoperability tests. */
public final class Sm2InteropCli {
private Sm2InteropCli() {
}
public static void main(String[] args) {
if (args.length != 3) {
throw new IllegalArgumentException("usage: encrypt <public-key> <plaintext> | decrypt <private-key> <ciphertext>");
}
switch (args[0]) {
case "encrypt" -> System.out.print(SM2Utils.encrypt(args[1], args[2]));
case "decrypt" -> System.out.print(SM2Utils.decrypt(args[1], args[2]));
default -> throw new IllegalArgumentException("unknown operation: " + args[0]);
}
}
}
@@ -0,0 +1,678 @@
from __future__ import annotations
import copy
import json
import subprocess
from collections.abc import Awaitable
from datetime import datetime
from pathlib import Path
from typing import Any, cast
from urllib.parse import quote_plus
import httpx
import pytest
import respx
from fastapi import Request
from fastapi.routing import APIRoute
from pydantic import ValidationError
from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.core.errors import AppError
from app.core.redis import JavaRedisCodec
from app.core.responses import error_response
from app.core.security import AuthUser
from app.integrations.llm import openai_completion
from app.integrations.mcp import build_agent_mcp_address, encrypt_agent_token
from app.integrations.voiceprint import VoicePrintClient, VoicePrintEndpoint, VoicePrintIntegrationError
from app.repositories.agent import AgentRepository
from app.routers.agent import router
from app.routers.agent import update_template as update_template_route
from app.schemas.agent import (
AgentChatHistoryReport,
AgentCreate,
AgentSnapshotPage,
AgentSnapshotRestore,
AgentTemplate,
AgentUpdate,
FunctionInfo,
)
from app.services.agent import (
SECRET_PLACEHOLDER,
AgentService,
_cached_datetime,
_changed_snapshot_fields,
_parse_snapshot_data,
_preserve_snapshot_sensitive,
_redact_snapshot_data,
_snapshot_state_token,
)
from tests.domain_support import sqlite_session
TARGET_ROOT = Path(__file__).resolve().parents[1]
REPOSITORY_ROOT = TARGET_ROOT.parents[1]
JAVA_BIN = REPOSITORY_ROOT / ".runtime" / "jdk" / "bin"
MCP_VECTOR_PATH = TARGET_ROOT / "tests" / "fixtures" / "agent-mcp-aes-golden.json"
EXPECTED_AGENT_ROUTES = {
("POST", "/agent/chat-history/report"),
("POST", "/agent/chat-history/getDownloadUrl/{agentId}/{sessionId}"),
("GET", "/agent/chat-history/download/{uuid}/current"),
("GET", "/agent/chat-history/download/{uuid}/previous"),
("GET", "/agent/template/page"),
("POST", "/agent/template/batch-remove"),
("GET", "/agent/template/{id}"),
("POST", "/agent/template"),
("PUT", "/agent/template"),
("DELETE", "/agent/template/{id}"),
("POST", "/agent/voice-print"),
("PUT", "/agent/voice-print"),
("DELETE", "/agent/voice-print/{id}"),
("GET", "/agent/voice-print/list/{id}"),
("GET", "/agent/mcp/address/{agentId}"),
("GET", "/agent/mcp/tools/{agentId}"),
("GET", "/agent/tag/list"),
("POST", "/agent/tag"),
("DELETE", "/agent/tag/{id}"),
("POST", "/agent/audio/{audioId}"),
("GET", "/agent/play/{uuid}"),
("PUT", "/agent/saveMemory/{macAddress}"),
("POST", "/agent/chat-summary/{sessionId}/save"),
("POST", "/agent/chat-title/{sessionId}/generate"),
("GET", "/agent/all"),
("GET", "/agent/list"),
("GET", "/agent/template"),
("GET", "/agent/{agentId}/snapshots"),
("GET", "/agent/{agentId}/snapshots/{snapshotId}"),
("POST", "/agent/{agentId}/snapshots/{snapshotId}/restore"),
("DELETE", "/agent/{agentId}/snapshots/{snapshotId}"),
("GET", "/agent/{id}/sessions"),
("GET", "/agent/{id}/chat-history/{sessionId}"),
("GET", "/agent/{id}/chat-history/user"),
("GET", "/agent/{id}/chat-history/audio"),
("GET", "/agent/{id}/tags"),
("PUT", "/agent/{id}/tags"),
("POST", "/agent"),
("PUT", "/agent/{id}"),
("DELETE", "/agent/{id}"),
("GET", "/agent/{id}"),
}
AGENT_SCHEMA = [
"""
CREATE TABLE ai_agent (
id TEXT PRIMARY KEY, user_id INTEGER, agent_code TEXT, agent_name TEXT, asr_model_id TEXT,
vad_model_id TEXT, llm_model_id TEXT, slm_model_id TEXT, vllm_model_id TEXT, tts_model_id TEXT,
tts_voice_id TEXT, tts_language TEXT, tts_volume INTEGER, tts_rate INTEGER, tts_pitch INTEGER,
mem_model_id TEXT, intent_model_id TEXT, chat_history_conf INTEGER, system_prompt TEXT,
summary_memory TEXT, lang_code TEXT, language TEXT, sort INTEGER, creator INTEGER, created_at DATETIME,
updater INTEGER, updated_at DATETIME
)
""",
"""
CREATE TABLE ai_agent_template (
id TEXT PRIMARY KEY, agent_code TEXT, agent_name TEXT, asr_model_id TEXT, vad_model_id TEXT,
llm_model_id TEXT, vllm_model_id TEXT, tts_model_id TEXT, tts_voice_id TEXT, tts_language TEXT,
tts_volume INTEGER, tts_rate INTEGER, tts_pitch INTEGER, mem_model_id TEXT, intent_model_id TEXT,
chat_history_conf INTEGER, system_prompt TEXT, summary_memory TEXT, lang_code TEXT, language TEXT,
sort INTEGER, creator INTEGER, created_at DATETIME, updater INTEGER, updated_at DATETIME
)
""",
"""
CREATE TABLE ai_model_config (
id TEXT PRIMARY KEY, model_type TEXT, model_code TEXT, model_name TEXT, is_default INTEGER,
is_enabled INTEGER, config_json TEXT, sort INTEGER
)
""",
"CREATE TABLE ai_model_provider (id TEXT PRIMARY KEY, provider_code TEXT, fields TEXT)",
"CREATE TABLE ai_tts_voice (id TEXT PRIMARY KEY, tts_model_id TEXT, tts_voice TEXT, name TEXT, languages TEXT)",
"CREATE TABLE ai_voice_clone (id TEXT PRIMARY KEY, name TEXT, languages TEXT)",
"""
CREATE TABLE ai_agent_plugin_mapping (
id INTEGER PRIMARY KEY, agent_id TEXT, plugin_id TEXT, param_info TEXT, UNIQUE(agent_id, plugin_id)
)
""",
"""
CREATE TABLE ai_agent_context_provider (
id TEXT PRIMARY KEY, agent_id TEXT, context_providers TEXT, creator INTEGER, created_at DATETIME,
updater INTEGER, updated_at DATETIME
)
""",
"""
CREATE TABLE ai_agent_correct_word_mapping (
id TEXT PRIMARY KEY, agent_id TEXT, file_id TEXT, creator INTEGER, created_at DATETIME,
updater INTEGER, updated_at DATETIME, UNIQUE(agent_id, file_id)
)
""",
"""
CREATE TABLE ai_agent_tag (
id TEXT PRIMARY KEY, tag_name TEXT, sort INTEGER, deleted INTEGER, creator INTEGER,
created_at DATETIME, updater INTEGER, updated_at DATETIME
)
""",
"""
CREATE TABLE ai_agent_tag_relation (
id TEXT PRIMARY KEY, agent_id TEXT, tag_id TEXT, sort INTEGER, creator INTEGER,
created_at DATETIME, updater INTEGER, updated_at DATETIME
)
""",
"""
CREATE TABLE ai_agent_snapshot (
id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, user_id INTEGER, version_no INTEGER NOT NULL,
snapshot_data TEXT NOT NULL, changed_fields TEXT, source TEXT, restore_from_snapshot_id TEXT,
restore_from_version_no INTEGER, creator INTEGER, created_at DATETIME, redaction_version INTEGER,
UNIQUE(agent_id, version_no)
)
""",
"""
CREATE TABLE ai_device (
id TEXT PRIMARY KEY, user_id INTEGER, mac_address TEXT, agent_id TEXT, last_connected_at DATETIME
)
""",
"CREATE TABLE ai_device_address_book (mac_address TEXT, target_mac TEXT)",
"""
CREATE TABLE ai_agent_chat_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, mac_address TEXT, agent_id TEXT, session_id TEXT,
chat_type INTEGER, content TEXT, audio_id TEXT, created_at DATETIME
)
""",
"""
CREATE TABLE ai_agent_chat_title (
id TEXT PRIMARY KEY, session_id TEXT, title TEXT, created_at DATETIME, updated_at DATETIME
)
""",
"CREATE TABLE ai_agent_chat_audio (id TEXT PRIMARY KEY, audio BLOB)",
]
def normal_user(user_id: int = 7) -> AuthUser:
return AuthUser(
id=user_id,
username="agent-user",
super_admin=0,
status=1,
token="test-token", # noqa: S106 - isolated authentication fixture
row={"id": user_id},
)
async def java_error_payload(awaitable: Awaitable[Any]) -> dict[str, Any]:
with pytest.raises(AppError) as captured:
await awaitable
request = Request({"type": "http", "method": "GET", "path": "/agent/test", "headers": []})
payload = json.loads(bytes(error_response(request, captured.value.code, captured.value.message).body))
assert isinstance(payload, dict)
return cast(dict[str, Any], payload)
@pytest.fixture(scope="module")
def mcp_vectors() -> list[dict[str, str]]:
value = json.loads(MCP_VECTOR_PATH.read_text(encoding="utf-8"))
assert isinstance(value, list)
return value
@pytest.fixture(scope="module")
def java_mcp_classpath(tmp_path_factory: pytest.TempPathFactory) -> Path:
classes = tmp_path_factory.mktemp("agent-mcp-java")
subprocess.run( # noqa: S603
[
str(JAVA_BIN / "javac"),
"-d",
str(classes),
str(
REPOSITORY_ROOT
/ "main"
/ "manager-api"
/ "src"
/ "main"
/ "java"
/ "xiaozhi"
/ "common"
/ "utils"
/ "AESUtils.java"
),
str(TARGET_ROOT / "tests" / "java" / "AgentMcpCryptoInteropCli.java"),
],
check=True,
capture_output=True,
text=True,
)
return classes
def test_agent_router_matches_all_six_java_controllers() -> None:
routes = [cast(APIRoute, route) for route in router.routes]
actual = {
(method, route.path)
for route in routes
for method in sorted(getattr(route, "methods", set()) or set())
}
assert actual == EXPECTED_AGENT_ROUTES
assert len(actual) == 41
paths = [route.path for route in routes]
assert paths.index("/agent/template/page") < paths.index("/agent/template/{id}")
assert paths.index("/agent/list") < paths.index("/agent/{id}")
assert paths.index("/agent/{agentId}/snapshots") < paths.index("/agent/{id}")
assert paths.index("/agent/{id}/chat-history/user") < paths.index("/agent/{id}/chat-history/{sessionId}")
assert paths.index("/agent/{id}/chat-history/audio") < paths.index("/agent/{id}/chat-history/{sessionId}")
@pytest.mark.parametrize(
("model", "payload", "message"),
[
(AgentCreate, {"agentName": " "}, "智能体名称不能为空"),
(
AgentChatHistoryReport,
{"macAddress": " ", "sessionId": "session", "chatType": 1, "content": "content"},
"不能为空",
),
(
AgentChatHistoryReport,
{"macAddress": "mac", "sessionId": "session", "chatType": None, "content": "content"},
"不能为空",
),
(AgentSnapshotRestore, {"currentStateToken": ""}, "不能为空"),
],
)
def test_agent_not_blank_constraints_keep_java_messages(
model: type[Any], payload: dict[str, Any], message: str
) -> None:
with pytest.raises(ValidationError) as captured:
model.model_validate(payload)
assert captured.value.errors()[0]["msg"] == message
@pytest.mark.asyncio
async def test_agent_missing_record_permission_and_validation_order_match_java() -> None:
missing = "contract-missing-agent"
snapshot_error = {"code": 500, "msg": "没有权限访问该智能体快照", "data": None}
permission_error = {"code": 10169, "msg": "您没有权限操作该记录", "data": None}
async with sqlite_session(AGENT_SCHEMA) as session:
service = AgentService(session, normal_user())
assert await java_error_payload(service.snapshot_page(missing, AgentSnapshotPage())) == snapshot_error
assert await java_error_payload(service.snapshot_detail(missing, "missing-snapshot")) == snapshot_error
assert await java_error_payload(
service.restore_snapshot(missing, "missing-snapshot", "valid-state-token")
) == snapshot_error
assert await java_error_payload(service.delete_snapshot(missing, "missing-snapshot")) == snapshot_error
assert await java_error_payload(service.sessions(missing, None, None)) == permission_error
assert await java_error_payload(service.audio_content("missing-audio")) == permission_error
assert await java_error_payload(service.agent_tags(missing)) == permission_error
assert await java_error_payload(service.save_agent_tags(missing, None, None)) == permission_error
await session.execute(
text("INSERT INTO ai_agent (id,user_id,agent_name) VALUES ('owned-agent',7,'Owned')")
)
await session.commit()
assert await java_error_payload(service.sessions("owned-agent", None, None)) == {
"code": 500,
"msg": "服务器内部异常",
"data": None,
}
assert await java_error_payload(service.sessions("owned-agent", "bad", "10")) == {
"code": 500,
"msg": "服务器内部异常",
"data": None,
}
assert await service.sessions("owned-agent", "1", "10") == {"list": [], "total": 0}
@pytest.mark.asyncio
async def test_template_update_without_id_keeps_java_generic_error() -> None:
request = Request({"type": "http", "method": "PUT", "path": "/agent/template", "headers": []})
async with sqlite_session(AGENT_SCHEMA) as session:
response = await update_template_route(AgentTemplate(), request, session, normal_user())
assert json.loads(bytes(response.body)) == {"code": 500, "msg": "服务器内部异常", "data": None}
def test_mcp_tokens_match_static_and_live_java_golden_vectors(
mcp_vectors: list[dict[str, str]], java_mcp_classpath: Path
) -> None:
for vector in mcp_vectors:
python_token = encrypt_agent_token(vector["agentId"], vector["key"])
java_token = subprocess.run( # noqa: S603
[
str(JAVA_BIN / "java"),
"-cp",
str(java_mcp_classpath),
"AgentMcpCryptoInteropCli",
vector["agentId"],
vector["key"],
],
check=True,
capture_output=True,
text=True,
).stdout
assert python_token == vector["token"]
assert java_token == vector["token"]
def test_mcp_address_preserves_java_path_scheme_and_form_encoding(mcp_vectors: list[dict[str, str]]) -> None:
vector = mcp_vectors[0]
endpoint = f"https://broker.example/xiaozhi/v1/?key={vector['key']}"
assert build_agent_mcp_address(endpoint, vector["agentId"]) == (
f"wss://broker.example/xiaozhi/v1/mcp/?token={quote_plus(vector['token'], safe='')}"
)
assert build_agent_mcp_address("null", vector["agentId"]) is None
def _snapshot_fixture() -> dict[str, Any]:
return {
"agentCode": "AGT_1",
"agentName": "agent",
"summaryMemory": None,
"functions": [
{
"pluginId": "plugin-a",
"paramInfo": {
"apiKey": "function-secret",
"safe": "visible",
"nested": {"password": "nested-secret"},
"webhookUrl": "https://hooks.slack.com/services/T/B/slack-secret?key=query-secret&safe=1",
},
}
],
"contextProviders": [
{
"url": "https://user:pass@example.com/context?api_key=url-secret&safe=1#token=fragment-secret",
"headers": {"Authorization": "Bearer header-secret", "X-Safe": "visible"},
}
],
"correctWordFileIds": ["file-b", "file-a"],
"tagNames": ["tag-b", "tag-a"],
"tags": [{"id": "tag-id", "tagName": "tag-a", "sort": 0}],
}
def test_snapshot_redaction_is_secret_free_and_reversible() -> None:
current = _parse_snapshot_data(_snapshot_fixture())
assert current is not None
redacted = _redact_snapshot_data(current)
assert redacted is not None
wire = json.dumps(redacted, ensure_ascii=False)
for secret in (
"function-secret",
"nested-secret",
"slack-secret",
"query-secret",
"user:pass",
"url-secret",
"fragment-secret",
"header-secret",
):
assert secret not in wire
assert wire.count(SECRET_PLACEHOLDER) >= 8
assert "safe=1" in wire
assert "visible" in wire
restored = _preserve_snapshot_sensitive(redacted, current)
assert restored == current
def test_snapshot_state_token_and_change_detection_match_java_normalization() -> None:
first = _parse_snapshot_data(_snapshot_fixture())
assert first is not None
second = copy.deepcopy(first)
second["functions"][0]["paramInfo"]["apiKey"] = "rotated-function-secret"
second["contextProviders"][0]["headers"]["Authorization"] = "Bearer rotated-header-secret"
second["correctWordFileIds"].reverse()
second["tagNames"].reverse()
second["summaryMemory"] = " "
assert _snapshot_state_token(first) == _snapshot_state_token(second)
assert _changed_snapshot_fields(first, second) == []
second["agentName"] = "changed"
assert _changed_snapshot_fields(first, second) == ["agentName"]
def test_snapshot_url_redaction_keeps_public_parameters_and_rejects_cross_origin_restore() -> None:
current = _parse_snapshot_data(_snapshot_fixture())
assert current is not None
redacted = _redact_snapshot_data(current)
assert redacted is not None
provider = redacted["contextProviders"][0]
assert provider["url"] == (
f"https://{SECRET_PLACEHOLDER}@example.com/context?api_key={SECRET_PLACEHOLDER}&safe=1"
f"#token={SECRET_PLACEHOLDER}"
)
provider["url"] = provider["url"].replace("example.com", "attacker.example")
with pytest.raises(Exception, match="URL 标识"):
_preserve_snapshot_sensitive(redacted, current)
def test_function_info_accepts_java_string_or_map_param_info() -> None:
from_string = FunctionInfo.model_validate({"pluginId": "p", "paramInfo": '{"answer":42}'})
from_map = FunctionInfo.model_validate({"pluginId": "p", "paramInfo": {"answer": 42}})
assert from_string.param_info == from_map.param_info == {"answer": 42}
def test_java_date_redis_wire_round_trip() -> None:
value = datetime(2026, 7, 20, 12, 34, 56, 789000)
encoded = JavaRedisCodec.encode(value)
assert json.loads(encoded) == ["java.util.Date", 1784522096789]
assert _cached_datetime(JavaRedisCodec.decode(encoded)) == value
@pytest.mark.asyncio
async def test_snapshot_repository_allocates_monotonic_versions_and_prunes() -> None:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
factory = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as connection:
await connection.execute(
text(
"CREATE TABLE ai_agent_snapshot ("
"id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, user_id INTEGER, version_no INTEGER NOT NULL,"
"snapshot_data TEXT NOT NULL, changed_fields TEXT, source TEXT, restore_from_snapshot_id TEXT,"
"restore_from_version_no INTEGER, creator INTEGER, created_at DATETIME, redaction_version INTEGER,"
"UNIQUE(agent_id, version_no))"
)
)
async with factory() as session:
repository = AgentRepository(session)
for index in range(1, 5):
assert (
await repository.insert_snapshot_next_version(
{
"id": f"snapshot-{index}",
"agent_id": "agent",
"user_id": 1,
"snapshot_data": "{}",
"changed_fields": "[]",
"source": "config",
"restore_from_snapshot_id": None,
"restore_from_version_no": None,
"creator": 1,
"created_at": datetime(2026, 7, 20, 12, index),
"redaction_version": 2,
}
)
== 1
)
assert await repository.snapshot_max_version("agent") == 4
assert await repository.prune_snapshots("agent", 2) == 2
rows = await repository.fetch_all(
"SELECT id,version_no FROM ai_agent_snapshot WHERE agent_id=:id ORDER BY version_no", {"id": "agent"}
)
assert rows == [{"id": "snapshot-3", "version_no": 3}, {"id": "snapshot-4", "version_no": 4}]
await engine.dispose()
@pytest.mark.asyncio
async def test_agent_create_update_restore_rollback_and_delete_are_transactional() -> None:
async with sqlite_session(AGENT_SCHEMA) as session:
await session.execute(
text(
"INSERT INTO ai_agent_template "
"(id,agent_name,llm_model_id,mem_model_id,system_prompt,summary_memory,lang_code,language,sort) "
"VALUES ('template','默认模板','llm-default','Memory_local_short','默认提示词','初始记忆',"
"'zh_CN','中文',1)"
)
)
await session.execute(
text(
"INSERT INTO ai_model_config "
"(id,model_type,model_name,is_default,is_enabled,config_json,sort) "
"VALUES ('llm-default','LLM','默认模型',1,1,:config,1)"
),
{
"config": json.dumps(
{
"type": "openai",
"base_url": "https://llm.invalid/v1",
"api_key": "mock-only",
"model_name": "mock",
}
)
},
)
for provider_id in ("SYSTEM_PLUGIN_MUSIC", "SYSTEM_PLUGIN_WEATHER", "SYSTEM_PLUGIN_NEWS_NEWSNOW"):
await session.execute(
text("INSERT INTO ai_model_provider (id,provider_code,fields) VALUES (:id,:id,:fields)"),
{
"id": provider_id,
"fields": json.dumps([{"key": "region", "default": "cn"}]),
},
)
await session.commit()
service = AgentService(session, normal_user())
agent_id = await service.create_agent(AgentCreate(agent_name="测试智能体"))
detail = await service.agent_detail(agent_id)
assert detail["agent_name"] == "测试智能体"
assert detail["chat_history_conf"] == 2
assert detail["current_version_no"] == 1
assert {item["plugin_id"] for item in detail["functions"]} == {
"SYSTEM_PLUGIN_MUSIC",
"SYSTEM_PLUGIN_WEATHER",
"SYSTEM_PLUGIN_NEWS_NEWSNOW",
}
await service.update_agent(
agent_id,
AgentUpdate.model_validate(
{
"agentName": "更新后的智能体",
"functions": [{"pluginId": "SYSTEM_PLUGIN_WEATHER", "paramInfo": {"region": "shanghai"}}],
"contextProviders": [{"url": "https://context.example/v1", "headers": {"X-Safe": "yes"}}],
"correctWordFileIds": ["words-1"],
"tagNames": ["家庭"],
}
),
)
updated = await service.agent_detail(agent_id)
assert updated["agent_name"] == "更新后的智能体"
assert [item["plugin_id"] for item in updated["functions"]] == ["SYSTEM_PLUGIN_WEATHER"]
assert updated["context_providers"] == [
{"url": "https://context.example/v1", "headers": {"X-Safe": "yes"}}
]
assert updated["correct_word_file_ids"] == ["words-1"]
assert updated["current_version_no"] == 2
assigned_tags = await service.agent_tags(agent_id)
assert len(assigned_tags) == 1
assert assigned_tags[0]["tagName"] == "家庭"
snapshot_rows = await service.repo.fetch_all(
"SELECT id,version_no,source FROM ai_agent_snapshot WHERE agent_id=:id ORDER BY version_no",
{"id": agent_id},
)
assert [(row["version_no"], row["source"]) for row in snapshot_rows] == [(1, "initial"), (2, "config")]
with pytest.raises(AppError):
await service.update_agent(
agent_id,
AgentUpdate.model_validate(
{"agentName": "不应提交", "llmModelId": "missing-model", "functions": []}
),
)
after_rollback = await service.agent_detail(agent_id)
assert after_rollback["agent_name"] == "更新后的智能体"
assert [item["plugin_id"] for item in after_rollback["functions"]] == ["SYSTEM_PLUGIN_WEATHER"]
assert after_rollback["current_version_no"] == 2
first_snapshot = snapshot_rows[0]
preview = await service.snapshot_detail(agent_id, str(first_snapshot["id"]))
await service.restore_snapshot(agent_id, str(first_snapshot["id"]), str(preview["currentStateToken"]))
restored = await service.agent_detail(agent_id)
assert restored["agent_name"] == "测试智能体"
assert restored["current_version_no"] == 3
assert {item["plugin_id"] for item in restored["functions"]} == {
"SYSTEM_PLUGIN_MUSIC",
"SYSTEM_PLUGIN_WEATHER",
"SYSTEM_PLUGIN_NEWS_NEWSNOW",
}
assert await service.agent_tags(agent_id) == []
assert await service.repo.scalar("SELECT COUNT(*) FROM ai_agent_tag") == 1
await service.delete_agent(agent_id)
assert await service.repo.get_agent(agent_id) is None
assert await service.repo.scalar(
"SELECT COUNT(*) FROM ai_agent_snapshot WHERE agent_id=:id", {"id": agent_id}
) == 0
assert await service.repo.scalar("SELECT COUNT(*) FROM ai_agent_tag") == 1
@pytest.mark.asyncio
async def test_voiceprint_mock_validates_java_multipart_and_error_mapping() -> None:
requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
body = await request.aread()
if request.url.path == "/voiceprint/identify":
assert b'name="speaker_ids"' in body
assert b"speaker-a,speaker-b" in body
assert b'filename="VoicePrint.WAV"' in body
return httpx.Response(200, json={"speaker_id": "speaker-a", "score": 0.75})
if request.url.path == "/voiceprint/register":
assert b'name="speaker_id"' in body
return httpx.Response(200, text="true")
return httpx.Response(503, text="unavailable")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
voiceprint = VoicePrintClient("http://voiceprint.example/service?key=test-secret", client=client)
assert await voiceprint.identify(["speaker-a", "speaker-b"], b"audio") == ("speaker-a", 0.75)
await voiceprint.register("speaker-a", b"audio")
with pytest.raises(VoicePrintIntegrationError) as error:
await voiceprint.cancel("speaker-a")
assert error.value.code == 10089
assert all(request.headers["Authorization"] == "Bearer test-secret" for request in requests)
assert VoicePrintEndpoint.parse("https://voiceprint.example:9443/path?key=secret") == VoicePrintEndpoint(
"https://voiceprint.example:9443", "Bearer secret"
)
@pytest.mark.asyncio
@respx.mock
async def test_llm_mock_validates_openai_request_and_thinking_policy() -> None:
route = respx.post("https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions").mock(
return_value=httpx.Response(200, json={"choices": [{"message": {"content": "summary"}}]})
)
result = await openai_completion(
{
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key": "mock-key",
"model_name": "mock-model",
},
"prompt",
temperature=0.2,
max_tokens=2000,
timeout=1,
)
assert result == "summary"
request = route.calls.last.request
payload = json.loads(request.content)
assert payload == {
"model": "mock-model",
"messages": [{"role": "user", "content": "prompt"}],
"temperature": 0.2,
"max_tokens": 2000,
"enable_thinking": False,
}
assert request.headers["Authorization"] == "Bearer mock-key"
@@ -0,0 +1,81 @@
from __future__ import annotations
import json
import re
import subprocess
import sys
from pathlib import Path
TARGET_ROOT = Path(__file__).resolve().parents[1]
REPOSITORY_ROOT = TARGET_ROOT.parents[1]
DOCUMENT = REPOSITORY_ROOT / "docs" / "manager-api-fastapi-compatibility.md"
RENDERER = TARGET_ROOT / "scripts" / "render_compatibility_doc.py"
JAVA_MANIFEST = TARGET_ROOT / "compatibility" / "java-routes.json"
ROUTE_SURFACE_RESULTS = TARGET_ROOT / "compatibility" / "route-surface-results.json"
AUTHENTICATED_ROUTE_RESULTS = TARGET_ROOT / "compatibility" / "authenticated-route-results.json"
def test_compatibility_document_is_current() -> None:
rendered = subprocess.run( # noqa: S603
[sys.executable, str(RENDERER)],
check=True,
capture_output=True,
text=True,
).stdout
assert DOCUMENT.read_text(encoding="utf-8") == rendered
def test_compatibility_document_has_every_java_route_exactly_once() -> None:
document = DOCUMENT.read_text(encoding="utf-8")
routes = json.loads(JAVA_MANIFEST.read_text(encoding="utf-8"))["routes"]
rows = re.findall(r"^\| J\d{3} \|.*$", document, flags=re.MULTILINE)
assert len(rows) == 154 == len(routes)
for route, row in zip(routes, rows, strict=True):
assert f"`{route['method']} {route['path']}`" in row
assert f"`{route['controller']}.{route['handler']}`" in row
assert "结构✓" in row
assert "请求面差分✓1" in row
assert "认证业务面差分✓1" in row
assert "领域✓" in row
assert "差分" in row
def test_document_separates_structure_domain_and_actual_differential_evidence() -> None:
document = DOCUMENT.read_text(encoding="utf-8")
assert "154/154100%" in document
assert "49/49 checks 通过、0 失败、0 跳过" in document
assert "154/154 通过、0 失败、0 跳过" in document
assert "authenticated-route-results.json" in document
assert "21/154" in document
assert "188" in document and "140" in document
assert "24 个、154 条映射" in document
assert "MyBatis XML20 个" in document
assert "101 个 `changeSet`" in document
for orphan in (
"GET /api/ping",
"PUT /user/configDevice/{device_id}",
"GET /device/address-book/lookup",
):
assert orphan in document
def test_document_evidence_requires_two_complete_all_route_differentials() -> None:
expected = {"total": 154, "passed": 154, "failed": 0, "skipped": 0}
expected_names = [
f"{route['method']} {route['path']}"
for route in json.loads(JAVA_MANIFEST.read_text(encoding="utf-8"))["routes"]
]
route_surface = json.loads(ROUTE_SURFACE_RESULTS.read_text(encoding="utf-8"))
authenticated_route = json.loads(AUTHENTICATED_ROUTE_RESULTS.read_text(encoding="utf-8"))
assert route_surface["summary"] == expected
assert authenticated_route["summary"] == expected
assert route_surface["coverage"]["request_profile"] == "missing-auth-or-safe-invalid-input"
assert authenticated_route["coverage"]["request_profile"] == (
"authenticated-safe-business-or-validation"
)
for evidence in (route_surface, authenticated_route):
assert [result["name"] for result in evidence["results"]] == expected_names
assert all(
result["passed"] is True and result["difference"] is None
for result in evidence["results"]
)
@@ -0,0 +1,281 @@
from __future__ import annotations
import json
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import AppError
from app.core.redis import JavaRedisCodec
from app.repositories.config import ConfigRepository
from app.routers.config import config_router
from app.services.config import ConfigService
from tests.domain_support import FakeRedis, sqlite_session
CONFIG_SCHEMA = [
"CREATE TABLE sys_params ("
"id INTEGER PRIMARY KEY, param_code TEXT UNIQUE, param_value TEXT, value_type TEXT, param_type INTEGER)",
"CREATE TABLE ai_agent_template ("
"id TEXT PRIMARY KEY, agent_code TEXT, agent_name TEXT, asr_model_id TEXT, vad_model_id TEXT, "
"llm_model_id TEXT, slm_model_id TEXT, vllm_model_id TEXT, tts_model_id TEXT, tts_voice_id TEXT, "
"tts_language TEXT, tts_volume INTEGER, tts_rate INTEGER, tts_pitch INTEGER, mem_model_id TEXT, "
"intent_model_id TEXT, chat_history_conf INTEGER, system_prompt TEXT, summary_memory TEXT, "
"lang_code TEXT, language TEXT, sort INTEGER)",
"CREATE TABLE ai_device ("
"id TEXT PRIMARY KEY, user_id INTEGER, mac_address TEXT UNIQUE, board TEXT, agent_id TEXT, "
"app_version TEXT, auto_update INTEGER)",
"CREATE TABLE ai_agent ("
"id TEXT PRIMARY KEY, user_id INTEGER, agent_code TEXT, agent_name TEXT, asr_model_id TEXT, "
"vad_model_id TEXT, llm_model_id TEXT, slm_model_id TEXT, vllm_model_id TEXT, tts_model_id TEXT, "
"tts_voice_id TEXT, tts_language TEXT, tts_volume INTEGER, tts_rate INTEGER, tts_pitch INTEGER, "
"mem_model_id TEXT, intent_model_id TEXT, chat_history_conf INTEGER, system_prompt TEXT, "
"summary_memory TEXT, lang_code TEXT, language TEXT)",
"CREATE TABLE ai_model_config ("
"id TEXT PRIMARY KEY, model_type TEXT, model_code TEXT, model_name TEXT, is_default INTEGER, "
"is_enabled INTEGER, config_json TEXT, doc_link TEXT, remark TEXT, sort INTEGER, creator INTEGER, "
"create_date DATETIME, updater INTEGER, update_date DATETIME)",
"CREATE TABLE ai_tts_voice ("
"id TEXT PRIMARY KEY, languages TEXT, name TEXT, remark TEXT, reference_audio TEXT, "
"reference_text TEXT, sort INTEGER, tts_model_id TEXT, tts_voice TEXT, voice_demo TEXT)",
"CREATE TABLE ai_voice_clone ("
"id TEXT PRIMARY KEY, name TEXT, model_id TEXT, voice_id TEXT, languages TEXT, user_id INTEGER, "
"train_status INTEGER, train_error TEXT)",
"CREATE TABLE ai_agent_plugin_mapping ("
"id TEXT PRIMARY KEY, agent_id TEXT, plugin_id TEXT, param_info TEXT)",
"CREATE TABLE ai_model_provider (id TEXT PRIMARY KEY, provider_code TEXT)",
"CREATE TABLE ai_rag_dataset ("
"id TEXT PRIMARY KEY, dataset_id TEXT, rag_model_id TEXT, name TEXT, description TEXT, status INTEGER)",
"CREATE TABLE ai_agent_context_provider (agent_id TEXT PRIMARY KEY, context_providers TEXT)",
"CREATE TABLE ai_agent_voice_print ("
"id TEXT PRIMARY KEY, agent_id TEXT, source_name TEXT, introduce TEXT, create_date DATETIME)",
"CREATE TABLE ai_agent_correct_word_mapping (agent_id TEXT, file_id TEXT)",
"CREATE TABLE ai_agent_correct_word_item (file_id TEXT, source_word TEXT, target_word TEXT)",
]
async def _seed_config(session: AsyncSession) -> None:
execute = session.execute
await execute(
text(
"INSERT INTO sys_params VALUES "
"(1, 'server.port', '8000', 'number', 1),"
"(2, 'features.enabled', 'true', 'boolean', 1),"
"(3, 'allowed.languages', 'zh; en ;', 'array', 1),"
"(4, 'device_max_output_size', '2048', 'string', 1),"
"(5, 'server.mcp_endpoint', 'https://mcp.example.test/mcp/?key=0123456789abcdef', 'string', 1),"
"(6, 'server.voice_print', 'https://voice.example.test/identify', 'string', 1),"
"(7, 'server.voiceprint_similarity_threshold', '0.73', 'number', 1)"
)
)
await execute(
text(
"INSERT INTO ai_agent_template "
"(id, agent_code, agent_name, asr_model_id, vad_model_id, sort) "
"VALUES ('template-1', 'default', 'Default', 'asr-1', 'vad-1', 1)"
)
)
models = [
("vad-1", "VAD", "vad", {"type": "silero"}),
("asr-1", "ASR", "asr", {"type": "funasr"}),
("llm-1", "LLM", "llm", {"type": "openai", "model": "mock"}),
("tts-1", "TTS", "tts", {"type": "huoshan_double_stream"}),
("mem-1", "Memory", "mem", {"type": "mem_local_short", "llm": "llm-1"}),
("intent-1", "Intent", "intent", {"type": "intent_llm", "llm": "llm-1", "functions": "a;b"}),
("rag-1", "RAG", "ragflow", {"base_url": "https://rag.test", "api_key": "mock-key"}),
]
for index, (model_id, model_type, model_code, configuration) in enumerate(models, start=1):
await execute(
text(
"INSERT INTO ai_model_config "
"(id, model_type, model_code, model_name, is_default, is_enabled, config_json, sort) "
"VALUES (:id, :type, :code, :name, 0, 1, :config, :sort)"
),
{
"id": model_id,
"type": model_type,
"code": model_code,
"name": model_code,
"config": json.dumps(configuration),
"sort": index,
},
)
@pytest.mark.asyncio
async def test_server_base_types_modules_and_redis_cache() -> None:
redis = FakeRedis()
async with sqlite_session(CONFIG_SCHEMA) as session:
await _seed_config(session)
await session.commit()
service = ConfigService(ConfigRepository(session), redis=redis) # type: ignore[arg-type]
result = await service.get_config(use_cache=False)
assert result["server"]["port"] == 8000
assert result["features"]["enabled"] is True
assert result["allowed"]["languages"] == ["zh", "en"]
assert ConfigService._typed_param("2147483647", "number") == 2147483647
assert ConfigService._typed_param("2147483648", "number") == 2147483648.0
assert isinstance(ConfigService._typed_param("2147483648", "number"), float)
assert result["VAD"]["vad-1"] == {"type": "silero"}
assert result["ASR"]["asr-1"] == {"type": "funasr"}
assert result["selected_module"] == {"VAD": "vad-1", "ASR": "asr-1"}
assert JavaRedisCodec.decode(await redis.get("server:config")) == result
server_wire = json.loads((await redis.get("server:config")).decode())
assert server_wire["@class"] == "java.util.HashMap"
assert server_wire["server"]["@class"] == "java.util.HashMap"
assert 0 < await redis.ttl("server:config") <= 86400
await session.execute(text("UPDATE sys_params SET param_value = '9000' WHERE param_code = 'server.port'"))
await session.commit()
assert (await service.get_config(use_cache=True))["server"]["port"] == 8000
@pytest.mark.asyncio
async def test_agent_config_aggregation_selected_models_mcp_voiceprint_and_correct_words() -> None:
redis = FakeRedis()
async with sqlite_session(CONFIG_SCHEMA) as session:
await _seed_config(session)
await session.execute(
text(
"INSERT INTO ai_device VALUES "
"('device-1', 10, 'AA:BB:CC:DD:EE:FF', 'esp32s3', 'agent-1', '1.2.3', 1)"
)
)
await session.execute(
text(
"INSERT INTO ai_agent "
"(id, user_id, agent_code, agent_name, asr_model_id, vad_model_id, llm_model_id, "
"tts_model_id, tts_voice_id, tts_volume, tts_rate, tts_pitch, mem_model_id, intent_model_id, "
"system_prompt, summary_memory) VALUES "
"('agent-1', 10, 'a1', '小明', 'asr-1', 'vad-1', 'llm-1', 'tts-1', 'voice-1', "
"80, 2, -1, 'mem-1', 'intent-1', '你是{{assistant_name}}', 'memory')"
)
)
await session.execute(
text(
"INSERT INTO ai_tts_voice "
"(id, languages, name, reference_audio, reference_text, sort, tts_model_id, tts_voice) "
"VALUES ('voice-1', '普通话、English', 'Voice', 'ref.wav', '你好', 2147483648, "
"'tts-1', 'S_demo')"
)
)
await session.execute(
text("UPDATE ai_model_config SET creator = 2147483648, updater = 2147483649 WHERE id = 'tts-1'")
)
await session.execute(text("INSERT INTO ai_model_provider VALUES ('weather-plugin', 'weather')"))
await session.execute(
text(
"INSERT INTO ai_agent_plugin_mapping VALUES "
"('map-1', 'agent-1', 'weather-plugin', '{\"city\":\"shanghai\"}'),"
"('map-2', 'agent-1', 'dataset-row', NULL)"
)
)
await session.execute(
text(
"INSERT INTO ai_rag_dataset VALUES "
"('dataset-row', 'dataset-external', 'rag-1', 'FAQ', 'product questions', 1)"
)
)
await session.execute(
text("INSERT INTO ai_agent_context_provider VALUES ('agent-1', '[{\"type\":\"weather\"}]')")
)
await session.execute(
text(
"INSERT INTO ai_agent_voice_print VALUES "
"('vp-1', 'agent-1', 'Alice', 'owner', '2026-01-02 03:04:05')"
)
)
await session.execute(text("INSERT INTO ai_agent_correct_word_mapping VALUES ('agent-1', 'file-1')"))
await session.execute(
text(
"INSERT INTO ai_agent_correct_word_item VALUES "
"('file-1', '晓智', '小智'), ('file-1', NULL, '空值')"
)
)
await session.commit()
service = ConfigService(ConfigRepository(session), redis=redis) # type: ignore[arg-type]
result = await service.get_agent_models("AA:BB:CC:DD:EE:FF", {"VAD": "vad-1"})
assert result["device_max_output_size"] == "2048"
assert result["chat_history_conf"] == 2
assert "VAD" not in result and result["selected_module"]["ASR"] == "asr-1"
assert result["prompt"] == "你是小明"
assert result["TTS"]["tts-1"] == {
"type": "huoshan_double_stream",
"private_voice": "S_demo",
"ref_audio": "ref.wav",
"ref_text": "你好",
"language": "普通话",
"ttsVolume": 80,
"ttsRate": 2,
"ttsPitch": -1,
"resource_id": "seed-icl-1.0",
}
assert result["Intent"]["intent-1"]["functions"] == ["a", "b"]
assert result["plugins"]["weather"] == '{"city":"shanghai"}'
rag_plugin = json.loads(result["plugins"]["search_from_ragflow"])
assert rag_plugin["dataset_ids"] == ["dataset-external"]
# This preserves URI.getSchemeSpecificPart() in Java, including its nested /mcp segment.
assert result["mcp_endpoint"].startswith("wss://mcp.example.test/call/mcp/?token=")
assert result["context_providers"] == [{"type": "weather"}]
assert result["voiceprint"] == {
"url": "https://voice.example.test/identify",
"speakers": ["vp-1,Alice,owner"],
"similarity_threshold": 0.73,
}
assert set(await service.get_correct_words("AA:BB:CC:DD:EE:FF")) == {"晓智|小智", "null|空值"}
model_wire = json.loads((await redis.get("model:data:tts-1")).decode())
assert model_wire["@class"] == "xiaozhi.modules.model.entity.ModelConfigEntity"
assert model_wire["configJson"]["@class"] == "cn.hutool.json.JSONObject"
assert model_wire["creator"] == 2147483648
assert model_wire["updater"] == 2147483649
timbre_wire = json.loads((await redis.get("timbre:details:voice-1")).decode())
assert timbre_wire["@class"] == "xiaozhi.modules.timbre.vo.TimbreDetailsVO"
assert timbre_wire["sort"] == 2147483648
@pytest.mark.asyncio
async def test_temporary_admin_config_is_one_shot_and_activation_code_error_is_preserved() -> None:
redis = FakeRedis()
async with sqlite_session(CONFIG_SCHEMA) as session:
await _seed_config(session)
await session.commit()
service = ConfigService(ConfigRepository(session), redis=redis) # type: ignore[arg-type]
await redis.set("tmp_register_mac:temporary", JavaRedisCodec.encode("true"), ex=300)
temporary = await service.get_agent_models("temporary", {})
assert temporary["selected_module"] == {"VAD": "vad-1", "ASR": "asr-1"}
assert await redis.get("tmp_register_mac:temporary") is None
await redis.set(
"ota:activation:data:aa_bb_cc_dd_ee_11",
JavaRedisCodec.encode({"activation_code": "654321"}),
ex=300,
)
with pytest.raises(AppError) as captured:
await service.get_agent_models("AA:BB:CC:DD:EE:11", {})
assert captured.value.code == 10042
assert captured.value.params == ("654321",)
@pytest.mark.asyncio
async def test_invalid_model_config_fails_like_java_jackson_type_handler() -> None:
redis = FakeRedis()
async with sqlite_session(CONFIG_SCHEMA) as session:
await _seed_config(session)
await session.execute(text("UPDATE ai_model_config SET config_json = '{' WHERE id = 'vad-1'"))
await session.commit()
service = ConfigService(ConfigRepository(session), redis=redis) # type: ignore[arg-type]
with pytest.raises(json.JSONDecodeError):
await service.get_config(use_cache=False)
def test_config_router_exposes_all_config_controller_routes() -> None:
routes = {(method, route.path) for route in config_router.routes for method in route.methods}
assert routes == {
("POST", "/config/server-base"),
("POST", "/config/agent-models"),
("POST", "/config/correct-words"),
}
@@ -0,0 +1,84 @@
from __future__ import annotations
import json
import re
import subprocess
import sys
from pathlib import Path
from app.main import app
TARGET_ROOT = Path(__file__).resolve().parents[1]
MANIFEST = TARGET_ROOT / "compatibility" / "consumer-routes.json"
EXTRACTOR = TARGET_ROOT / "scripts" / "extract_consumer_routes.py"
PATH_PARAMETER = re.compile(r"\{[^/{}]+\}")
def live_manifest() -> dict[str, object]:
result = subprocess.run( # noqa: S603
[sys.executable, str(EXTRACTOR)],
check=True,
capture_output=True,
text=True,
)
return json.loads(result.stdout)
def _route_matches(declared: str, consumed: str) -> bool:
declared_parts = declared.strip("/").split("/")
consumed_parts = consumed.strip("/").split("/")
if len(declared_parts) != len(consumed_parts):
return False
return all(
PATH_PARAMETER.fullmatch(declared_part) is not None or declared_part == consumed_part
for declared_part, consumed_part in zip(declared_parts, consumed_parts, strict=True)
)
def test_checked_in_consumer_route_manifest_is_current() -> None:
checked_in = json.loads(MANIFEST.read_text(encoding="utf-8"))
assert checked_in == live_manifest()
def test_consumer_inventory_counts_are_closed() -> None:
manifest = live_manifest()
assert manifest["count"] == 188
assert manifest["uniqueRoutes"] == 140
assert manifest["consumers"] == {
"manager-web": {
"callSites": 134,
"uniqueRoutes": 130,
"methods": {"DELETE": 12, "GET": 59, "POST": 40, "PUT": 23},
},
"manager-mobile": {
"callSites": 46,
"uniqueRoutes": 40,
"methods": {"DELETE": 3, "GET": 26, "POST": 12, "PUT": 5},
},
"xiaozhi-server": {
"callSites": 8,
"uniqueRoutes": 8,
"methods": {"GET": 2, "POST": 6},
},
}
def test_every_consumer_call_resolves_to_a_fastapi_route() -> None:
calls = live_manifest()["calls"]
assert isinstance(calls, list)
actual = [
(method, route.path.removeprefix("/xiaozhi"))
for route in app.routes
for method in getattr(route, "methods", set())
if method in {"GET", "POST", "PUT", "DELETE", "PATCH"}
and route.path.startswith("/xiaozhi")
]
missing = [
call
for call in calls
if not any(
method == str(call["method"]) and _route_matches(route_path, str(call["path"]))
for method, route_path in actual
)
]
assert missing == []
@@ -0,0 +1,226 @@
from __future__ import annotations
import hashlib
import json
from datetime import datetime
import pytest
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from app.core.crypto import bcrypt_hash, bcrypt_matches, generate_database_token
from app.core.errors import ErrorCode
from app.core.i18n import message_for, resolve_language
from app.core.ids import SnowflakeIdGenerator
from app.core.redis import JavaRedisCodec
from app.core.responses import JavaJSONResponse, envelope, error_response
from app.core.serialization import java_compatible, preserve_java_map_keys
from app.main import validation_error_handler
def test_java_response_envelope_always_contains_null_data() -> None:
response = JavaJSONResponse(envelope(code=401, msg="Unauthorized"))
assert json.loads(response.body) == {"code": 401, "msg": "Unauthorized", "data": None}
def test_auth_filter_can_preserve_java_content_type_without_starlette_charset_rewrite() -> None:
request = Request({"type": "http", "method": "GET", "path": "/xiaozhi/user/info", "headers": []})
response = error_response(request, 401, media_type="application/json;charset=utf-8")
assert response.headers["content-type"] == "application/json;charset=utf-8"
@pytest.mark.asyncio
async def test_absent_request_body_uses_java_generic_error_but_field_missing_uses_10034() -> None:
request = Request({"type": "http", "method": "POST", "path": "/xiaozhi/user/login", "headers": []})
absent = RequestValidationError(
[{"type": "missing", "loc": ("body",), "msg": "Field required", "input": None}]
)
absent_response = await validation_error_handler(request, absent)
assert json.loads(absent_response.body) == {
"code": ErrorCode.INTERNAL_SERVER_ERROR,
"msg": "服务器内部异常",
"data": None,
}
field_missing = RequestValidationError(
[{"type": "missing", "loc": ("body", "username"), "msg": "Field required", "input": {}}]
)
field_response = await validation_error_handler(request, field_missing)
assert json.loads(field_response.body) == {
"code": ErrorCode.PARAM_VALUE_NULL,
"msg": "Field required",
"data": None,
}
@pytest.mark.asyncio
async def test_java_binding_failures_use_generic_error_without_changing_json_field_validation() -> None:
root_type_request = Request(
{"type": "http", "method": "POST", "path": "/xiaozhi/admin/dict/data/delete", "headers": []}
)
root_type = RequestValidationError(
[{"type": "list_type", "loc": ("body",), "msg": "Input should be a valid list", "input": {}}]
)
root_response = await validation_error_handler(root_type_request, root_type)
assert json.loads(root_response.body) == {
"code": ErrorCode.INTERNAL_SERVER_ERROR,
"msg": "服务器内部异常",
"data": None,
}
query_request = Request(
{"type": "http", "method": "GET", "path": "/xiaozhi/models/list", "headers": []}
)
missing_query = RequestValidationError(
[{"type": "missing", "loc": ("query", "modelType"), "msg": "Field required", "input": None}]
)
query_response = await validation_error_handler(query_request, missing_query)
assert json.loads(query_response.body)["code"] == ErrorCode.INTERNAL_SERVER_ERROR
multipart_request = Request(
{"type": "http", "method": "POST", "path": "/xiaozhi/voiceClone/upload", "headers": []}
)
missing_file = RequestValidationError(
[{"type": "missing", "loc": ("body", "file"), "msg": "Field required", "input": None}]
)
multipart_response = await validation_error_handler(multipart_request, missing_file)
assert json.loads(multipart_response.body)["code"] == ErrorCode.INTERNAL_SERVER_ERROR
json_request = Request(
{"type": "http", "method": "POST", "path": "/xiaozhi/unmapped-json", "headers": []}
)
missing_field = RequestValidationError(
[{"type": "missing", "loc": ("body", "name"), "msg": "Field required", "input": {}}]
)
json_response = await validation_error_handler(json_request, missing_field)
assert json.loads(json_response.body) == {
"code": ErrorCode.PARAM_VALUE_NULL,
"msg": "Field required",
"data": None,
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("path", "field", "expected"),
[
("/xiaozhi/admin/server/emit-action", "action", "操作不能为空"),
("/xiaozhi/admin/server/emit-action", "targetWs", "目标ws地址不能为空"),
("/xiaozhi/agent", "agentName", "智能体名称不能为空"),
("/xiaozhi/agent/chat-history/report", "macAddress", "不能为空"),
(
"/xiaozhi/agent/missing/snapshots/missing/restore",
"currentStateToken",
"不能为空",
),
("/xiaozhi/config/agent-models", "macAddress", "设备MAC地址不能为空"),
("/xiaozhi/config/agent-models", "clientId", "客户端ID不能为空"),
(
"/xiaozhi/config/agent-models",
"selectedModule",
"客户端已实例化的模型不能为空",
),
("/xiaozhi/config/correct-words", "macAddress", "设备MAC地址不能为空"),
("/xiaozhi/device/address-book/alias", "targetMac", "目标MAC地址不能为空"),
("/xiaozhi/device/address-book/alias", "macAddress", "MAC地址不能为空"),
],
)
async def test_required_json_fields_keep_java_constraint_messages(
path: str, field: str, expected: str
) -> None:
request = Request({"type": "http", "method": "POST", "path": path, "headers": []})
missing = RequestValidationError(
[{"type": "missing", "loc": ("body", field), "msg": "Field required", "input": {}}]
)
response = await validation_error_handler(request, missing)
assert json.loads(response.body) == {
"code": ErrorCode.PARAM_VALUE_NULL,
"msg": expected,
"data": None,
}
def test_dynamic_java_map_keys_are_not_changed_by_property_naming() -> None:
response = JavaJSONResponse(
envelope(
preserve_java_map_keys(
{"selected_module": {"wake_up_words": ["你好"]}, "timestamp": 2**32}
)
)
)
assert json.loads(response.body)["data"] == {
"selected_module": {"wake_up_words": ["你好"]},
"timestamp": str(2**32),
}
def test_long_dates_aliases_and_nulls_follow_jackson_configuration() -> None:
payload = java_compatible(
{
"id": 7,
"agent_id": 1890000000000000000,
"word_count": 3,
"created_at": datetime(2026, 7, 20, 9, 8, 7),
"optional_value": None,
}
)
assert payload == {
"id": "7",
"agentId": "1890000000000000000",
"wordCount": 3,
"createdAt": "2026-07-20 09:08:07",
"optionalValue": None,
}
# PageData.total is declared as an Integer in the Java baseline, not a Long.
assert java_compatible({"total": 1}) == {"total": 1}
def test_accept_language_uses_first_value_and_supported_fallbacks() -> None:
assert resolve_language("de-DE;q=0.1,en-US;q=1") == "de-DE"
assert message_for(401, "zh-CN") == "未授权"
assert message_for(401, "zh-TW") == "未授權"
assert message_for(401, "en") == "Unauthorized"
assert message_for(401, "de") == "Nicht autorisiert"
assert message_for(401, "vi") == "Không được ủy quyền"
assert message_for(401, "pt-BR") == "Não autorizado"
assert message_for(401, "unknown") == "未授权"
def test_java_redis_json_wire_format_for_protocol_scalars() -> None:
assert JavaRedisCodec.encode("abc") == b'"abc"'
assert JavaRedisCodec.encode(3) == b"3"
assert JavaRedisCodec.encode(["a", "b"]) == b'["java.util.ArrayList",["a","b"]]'
assert JavaRedisCodec.encode({"nested": {"enabled": True}}) == (
b'{"@class":"java.util.HashMap","nested":{"@class":"java.util.HashMap","enabled":true}}'
)
assert JavaRedisCodec.encode(2147483648) == b"2147483648"
assert JavaRedisCodec.encode({"number": 2147483648}) == (
b'{"@class":"java.util.HashMap","number":["java.lang.Long",2147483648]}'
)
assert JavaRedisCodec.decode(b'["java.util.ArrayList",["a","b"]]') == ["a", "b"]
assert JavaRedisCodec.decode(b'"abc"') == "abc"
assert JavaRedisCodec.decode(b"legacy-unquoted") == "legacy-unquoted"
def test_database_token_is_java_md5_uuid_format() -> None:
expected = hashlib.md5(b"fixed-input", usedforsecurity=False).hexdigest()
assert generate_database_token("fixed-input") == expected
generated = generate_database_token()
assert len(generated) == 32
assert generated == generated.lower()
def test_bcrypt_output_is_accepted_by_bundled_java_encoder() -> None:
encoded = bcrypt_hash("correct horse battery staple")
assert encoded.startswith("$2a$10$")
assert bcrypt_matches("correct horse battery staple", encoded)
assert not bcrypt_matches("wrong", encoded)
assert not bcrypt_matches("correct horse battery staple", encoded.replace("$2a$", "$2b$", 1))
def test_snowflake_ids_are_unique_monotonic_and_fit_signed_bigint() -> None:
generator = SnowflakeIdGenerator(worker_id=1, datacenter_id=2)
values = [generator.next_id() for _ in range(1000)]
assert values == sorted(values)
assert len(set(values)) == len(values)
assert all(0 < value < 2**63 for value in values)
@@ -0,0 +1,192 @@
from __future__ import annotations
import os
import subprocess
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[1]
def test_runtime_dependency_versions_are_explicitly_locked() -> None:
project = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
lock = (ROOT / "uv.lock").read_text(encoding="utf-8")
assert '"fastapi==0.116.1"' in project
assert '"pydantic==2.11.7"' in project
assert '"uvicorn[standard]==0.35.0"' in project
assert 'name = "pydantic"\nversion = "2.11.7"' in lock
def test_container_artifacts_are_pinned_and_preserve_the_java_upload_path() -> None:
dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
migration_dockerfile = (ROOT / "Dockerfile.migrations").read_text(encoding="utf-8")
migration_pom = (ROOT / "migration-pom.xml").read_text(encoding="utf-8")
assert dockerfile.startswith("FROM python:3.10.20-bookworm AS build\n")
assert dockerfile.count("FROM python:3.10.20-slim-bookworm") == 1
assert "COPY --from=ghcr.io/astral-sh/uv:0.11.28 /uv /uvx /bin/" in dockerfile
assert "UV_HTTP_TIMEOUT=120" in dockerfile
assert "UV_HTTP_RETRIES=10" in dockerfile
assert "apt-get" not in dockerfile
assert "COPY --from=build --chown=10001:10001 /app/.venv ./.venv" in dockerfile
assert "USER 10001:10001" in dockerfile
assert "ln -s /data/uploads /app/uploadfile" in dockerfile
assert 'STOPSIGNAL SIGTERM' in dockerfile
assert "FROM maven:3.9.9-eclipse-temurin-21 AS build" in migration_dockerfile
assert "FROM eclipse-temurin:21-jre" in migration_dockerfile
assert "--mount=type=cache,target=/root/.m2/repository" in migration_dockerfile
assert "-Daether.connector.basic.threads=1" in migration_dockerfile
assert "-Daether.connector.connectTimeout=15000" in migration_dockerfile
assert "-Daether.connector.requestTimeout=60000" in migration_dockerfile
assert "-Daether.connector.http.retryHandler.count=5" in migration_dockerfile
assert "USER 10001:10001" in migration_dockerfile
assert "STOPSIGNAL SIGTERM" in migration_dockerfile
assert "manager-api-liquibase-runner-1.0.0-all.jar" in migration_dockerfile
assert "<shadedArtifactAttached>true</shadedArtifactAttached>" in migration_pom
assert "<shadedClassifierName>all</shadedClassifierName>" in migration_pom
assert ":latest" not in dockerfile + migration_dockerfile
def test_compose_separates_migrations_api_jobs_and_nginx() -> None:
compose = yaml.safe_load((ROOT / "docker-compose.yml").read_text(encoding="utf-8"))
services = compose["services"]
assert set(services) == {
"manager-api-migrate",
"manager-api-fastapi",
"manager-api-jobs",
"manager-api-nginx",
}
api = services["manager-api-fastapi"]
jobs = services["manager-api-jobs"]
assert api["depends_on"]["manager-api-migrate"]["condition"] == "service_completed_successfully"
assert jobs["command"] == ["python", "-m", "app.jobs.worker"]
assert api["read_only"] is True and jobs["read_only"] is True
assert api["stop_grace_period"] == "40s"
expected_upload = ["${MANAGER_API_UPLOAD_SOURCE:-manager-api-uploads}:/data/uploads"]
assert api["volumes"] == jobs["volumes"] == expected_upload
assert api["environment"]["APP_GRACEFUL_SHUTDOWN_SECONDS"] == "${APP_GRACEFUL_SHUTDOWN_SECONDS:-30}"
assert jobs["environment"]["APP_GRACEFUL_SHUTDOWN_SECONDS"] == "${APP_GRACEFUL_SHUTDOWN_SECONDS:-30}"
def test_nginx_health_and_startup_scripts_are_wired() -> None:
nginx = (ROOT / "deploy" / "nginx.conf").read_text(encoding="utf-8")
nginx_dockerfile = (ROOT / "Dockerfile.nginx").read_text(encoding="utf-8")
nginx_entrypoint = ROOT / "deploy" / "nginx-entrypoint.sh"
compose_text = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
compose = yaml.safe_load(compose_text)
nginx_service = compose["services"]["manager-api-nginx"]
assert "location /xiaozhi/" in nginx
assert "location = /xiaozhi" in nginx
assert "server ${MANAGER_API_UPSTREAM};" in nginx
assert "proxy_request_buffering off;" in nginx
assert "proxy_buffering off;" in nginx
assert "client_max_body_size 100m;" in nginx
assert nginx_dockerfile.startswith("FROM nginx:1.28.0-alpine\n")
assert ":latest" not in nginx_dockerfile
assert "envsubst '${MANAGER_API_UPSTREAM}'" in nginx_dockerfile
assert nginx_service["build"]["dockerfile"] == "main/manager-api-fastapi/Dockerfile.nginx"
assert nginx_service["environment"] == {
"MANAGER_API_UPSTREAM": "${MANAGER_API_UPSTREAM:-manager-api-fastapi:8002}"
}
assert nginx_service["read_only"] is True
assert set(nginx_service["tmpfs"]) == {
"/var/cache/nginx:size=32m",
"/var/run:size=1m",
"/tmp:size=16m", # noqa: S108 - literal deployment mount, not a test scratch path
}
assert nginx_service["depends_on"]["manager-api-fastapi"]["condition"] == "service_healthy"
assert "/xiaozhi/health/ready" in compose_text
assert os.access(nginx_entrypoint, os.X_OK)
syntax = subprocess.run( # noqa: S603
["/bin/sh", "-n", str(nginx_entrypoint)],
check=False,
capture_output=True,
text=True,
)
assert syntax.returncode == 0, syntax.stderr
for script_name in (
"container-entrypoint.sh",
"run-migrations.sh",
"isolated-env.sh",
"start-api.sh",
"start-jobs.sh",
):
script = ROOT / "scripts" / script_name
assert script.read_text(encoding="utf-8").startswith("#!/bin/sh\nset -eu\n")
assert os.access(script, os.X_OK), f"{script_name} must be executable"
def test_docker_context_excludes_local_runtimes_secrets_and_build_products() -> None:
dockerignore = (ROOT.parents[1] / ".dockerignore").read_text(encoding="utf-8")
for ignored in (
".env",
".runtime/",
".venv-*/",
"**/.venv/",
"**/.test-runtime/",
"**/node_modules/",
"**/dist/",
"**/target/",
"**/uploadfile/",
"main/xiaozhi-server/models/",
"main/xiaozhi-server/data/",
):
assert ignored in dockerignore
def test_start_scripts_are_executable_without_shell_reinterpretation() -> None:
environment = {**os.environ, "PYTHON_BIN": "/bin/echo"}
api = subprocess.run( # noqa: S603
[str(ROOT / "scripts" / "start-api.sh")],
check=True,
capture_output=True,
text=True,
env=environment,
)
assert "-m uvicorn app.main:app" in api.stdout
assert "--timeout-graceful-shutdown 30" in api.stdout
jobs = subprocess.run( # noqa: S603
[str(ROOT / "scripts" / "start-jobs.sh")],
check=True,
capture_output=True,
text=True,
env=environment,
)
assert jobs.stdout.strip() == "-m app.jobs.worker"
override = subprocess.run( # noqa: S603
[str(ROOT / "scripts" / "container-entrypoint.sh"), "/bin/echo", "override-ok"],
check=True,
capture_output=True,
text=True,
)
assert override.stdout.strip() == "override-ok"
def test_repository_local_restart_entrypoint_keeps_fastapi_default_and_java_rollback() -> None:
restart = ROOT.parents[1] / "scripts" / "restart-local-services.sh"
assert os.access(restart, os.X_OK)
syntax = subprocess.run( # noqa: S603
["/bin/sh", "-n", str(restart)],
check=False,
capture_output=True,
text=True,
)
assert syntax.returncode == 0, syntax.stderr
help_result = subprocess.run( # noqa: S603
[str(restart), "--help"],
check=True,
capture_output=True,
text=True,
)
assert "--manager-api fastapi|java" in help_result.stdout
assert "The FastAPI manager is the default" in help_result.stdout
def test_local_migration_script_prefers_bundled_jdk_maven_and_repository() -> None:
script = (ROOT / "scripts" / "run-migrations.sh").read_text(encoding="utf-8")
assert 'RUNTIME_ROOT="${PROJECT_DIR}/../../.runtime"' in script
assert 'MAVEN="${RUNTIME_ROOT}/maven/bin/mvn"' in script
assert 'MAVEN_REPOSITORY_ARGS="-Dmaven.repo.local=${RUNTIME_ROOT}/m2"' in script
assert 'JAVA="${RUNTIME_ROOT}/jdk/bin/java"' in script
assert " clean package" not in script
assert "manager-api-liquibase-runner-1.0.0-all.jar" in script
@@ -0,0 +1,173 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
from typing import cast
import pytest
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.jobs.worker import _finish_tasks, _fixed_delay_loop
from app.services.device import DeviceService, redis_set
from tests.domain_support import FakeRedis
TARGET_ROOT = Path(__file__).resolve().parents[1]
REPOSITORY_ROOT = TARGET_ROOT.parents[1]
def test_application_lifespan_starts_and_shuts_down_without_binding_a_port(tmp_path: Path) -> None:
upload_dir = tmp_path / "uploads"
code = (
"from fastapi.testclient import TestClient; "
"from app.main import app; "
"client=TestClient(app); "
"client.__enter__(); "
"response=client.get('/xiaozhi/health/live'); "
"assert response.status_code == 200, response.text; "
"client.__exit__(None, None, None)"
)
environment = {
**os.environ,
"APP_ENVIRONMENT": "test",
"APP_ALLOW_START_WITHOUT_DEPENDENCIES": "true",
"APP_DATABASE_URL": "sqlite+aiosqlite:///:memory:",
"APP_REDIS_URL": "redis://127.0.0.1:1/15",
"APP_UPLOAD_DIR": str(upload_dir),
"APP_JAVA_RESOURCES_DIR": str(REPOSITORY_ROOT / "main" / "manager-api" / "src" / "main" / "resources"),
}
result = subprocess.run( # noqa: S603
[sys.executable, "-c", code],
cwd=TARGET_ROOT,
env=environment,
check=False,
capture_output=True,
text=True,
timeout=20,
)
assert result.returncode == 0, result.stderr
assert upload_dir.is_dir()
@pytest.mark.asyncio
async def test_readiness_reports_dependency_state_with_real_http_status(monkeypatch: pytest.MonkeyPatch) -> None:
import app.main as main_module
async def available() -> bool:
return True
async def unavailable() -> bool:
return False
monkeypatch.setattr(main_module, "database_ping", available)
monkeypatch.setattr(main_module, "redis_ping", unavailable)
monkeypatch.setattr(main_module, "upload_storage_ready", lambda: True)
failed = await main_module.readiness()
assert failed.status_code == 503
assert json.loads(failed.body) == {
"code": 503,
"msg": "dependencies unavailable",
"data": {"database": True, "redis": False, "uploads": True},
}
monkeypatch.setattr(main_module, "redis_ping", available)
ready = await main_module.readiness()
assert ready.status_code == 200
assert json.loads(ready.body)["data"] == {
"database": True,
"redis": True,
"uploads": True,
}
monkeypatch.setattr(main_module, "upload_storage_ready", lambda: False)
unwritable = await main_module.readiness()
assert unwritable.status_code == 503
assert json.loads(unwritable.body)["data"] == {
"database": True,
"redis": True,
"uploads": False,
}
assert (await main_module.liveness()).status_code == 200
@pytest.mark.asyncio
async def test_job_shutdown_drains_active_work_before_cancelling() -> None:
stop = asyncio.Event()
started = asyncio.Event()
release = asyncio.Event()
async def operation() -> int:
started.set()
await release.wait()
return 1
task = asyncio.create_task(
_fixed_delay_loop(stop, operation, name="test", initial_delay=0, delay=60)
)
await started.wait()
stop.set()
drain = asyncio.create_task(_finish_tasks([task], timeout=1))
await asyncio.sleep(0)
assert not drain.done()
release.set()
assert await drain is True
assert task.done() and not task.cancelled()
@pytest.mark.asyncio
async def test_fixed_delay_job_runs_again_after_python_310_wait_timeout() -> None:
stop = asyncio.Event()
calls = 0
async def operation() -> int:
nonlocal calls
calls += 1
if calls == 2:
stop.set()
return calls
await asyncio.wait_for(
_fixed_delay_loop(stop, operation, name="timeout-regression", initial_delay=0, delay=0.01),
timeout=1,
)
assert calls == 2
@pytest.mark.asyncio
async def test_job_shutdown_cancels_only_after_timeout() -> None:
task = asyncio.create_task(asyncio.sleep(60))
assert await _finish_tasks([task], timeout=0.01) is False
assert task.cancelled()
@pytest.mark.asyncio
async def test_configured_upload_volume_keeps_java_relative_database_path(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
mounted = tmp_path / "mounted-uploads"
monkeypatch.setattr(
"app.services.device.get_settings",
lambda: SimpleNamespace(upload_dir=mounted),
)
redis = cast(Redis, FakeRedis())
service = DeviceService(cast(AsyncSession, object()), redis_client=redis)
content = b"deployment-upload"
digest = hashlib.md5(content, usedforsecurity=False).hexdigest()
java_path = f"uploadfile/{digest}.bin"
assert await service.save_firmware_file(filename="firmware.bin", content=content) == java_path
assert (mounted / f"{digest}.bin").read_bytes() == content
assert not (tmp_path / java_path).exists()
await redis_set("ota:id:volume-test", f"file:{java_path}", client=redis)
resolved = await service.resolve_ota_download("volume-test")
assert resolved is not None
assert resolved[0] == mounted / f"{digest}.bin"
assert resolved[0].read_bytes() == content
@@ -0,0 +1,348 @@
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, cast
import httpx
import pytest
from fastapi import FastAPI, Request
from redis.asyncio import Redis
from sqlalchemy import text
from app.core.database import get_db
from app.core.security import AuthUser
from app.integrations.mqtt_gateway import daily_authorization_tokens, post_json
from app.routers.device import _validate_device_update, device_router, unbind_device, update_address_alias
from app.schemas.device import (
ApplicationInfo,
BoardInfo,
DeviceAddressBookAliasRequest,
DeviceManualAddRequest,
DeviceReportRequest,
DeviceUnbindRequest,
DeviceUpdateRequest,
)
from app.services.device import DeviceService, redis_get, redis_set
from app.services.system_params import SystemParamService
from tests.domain_support import FakeRedis, sqlite_session
DEVICE_SCHEMA = [
"""
CREATE TABLE ai_device (
id VARCHAR(32) PRIMARY KEY, user_id BIGINT, mac_address VARCHAR(50), last_connected_at DATETIME,
auto_update INTEGER, board VARCHAR(50), alias VARCHAR(64), agent_id VARCHAR(32), app_version VARCHAR(20),
sort INTEGER, updater BIGINT, update_date DATETIME, creator BIGINT, create_date DATETIME
)
""",
"""
CREATE TABLE ai_ota (
id VARCHAR(32) PRIMARY KEY, firmware_name VARCHAR(100), type VARCHAR(50), version VARCHAR(50), size BIGINT,
remark VARCHAR(500), firmware_path VARCHAR(255), sort INTEGER, updater BIGINT, update_date DATETIME,
creator BIGINT, create_date DATETIME
)
""",
"""
CREATE TABLE ai_device_address_book (
mac_address VARCHAR(64), target_mac VARCHAR(64), alias VARCHAR(64), has_permission INTEGER,
creator BIGINT, create_date DATETIME, updater BIGINT, update_date DATETIME,
PRIMARY KEY (mac_address, target_mac)
)
""",
"CREATE TABLE sys_params (param_code VARCHAR(100) PRIMARY KEY, param_value TEXT, update_date DATETIME)",
]
def normal_user(user_id: int = 7, *, super_admin: int = 0) -> AuthUser:
return AuthUser(
id=user_id,
username="tester",
super_admin=super_admin,
status=1,
token="test-token", # noqa: S106 - isolated authentication fixture
row={"id": user_id},
)
def test_device_router_closes_all_assigned_paths_and_static_routes_win() -> None:
routes = [route for route in device_router.routes if hasattr(route, "methods")]
pairs = {(next(iter(route.methods)), route.path) for route in routes}
assert pairs == {
("POST", "/device/bind/{agent_id}/{device_code}"),
("POST", "/device/register"),
("GET", "/device/bind/{agent_id}"),
("POST", "/device/bind/{agent_id}"),
("POST", "/device/unbind"),
("PUT", "/device/update/{device_id}"),
("PUT", "/user/configDevice/{device_id}"),
("POST", "/device/manual-add"),
("POST", "/device/tools/list/{device_id}"),
("POST", "/device/tools/call/{device_id}"),
("GET", "/device/address-book/call"),
("GET", "/device/address-book/lookup"),
("PUT", "/device/address-book/alias"),
("PUT", "/device/address-book/permission"),
("GET", "/device/address-book/{mac_address}"),
("POST", "/ota/"),
("POST", "/ota/activate"),
("GET", "/ota/"),
("GET", "/otaMag/getDownloadUrl/{ota_id}"),
("GET", "/otaMag/download/{download_id}"),
("POST", "/otaMag/upload"),
("POST", "/otaMag/uploadAssetsBin"),
("GET", "/otaMag"),
("GET", "/otaMag/{ota_id}"),
("POST", "/otaMag"),
("DELETE", "/otaMag/{ota_id}"),
("PUT", "/otaMag/{ota_id}"),
}
assert len(routes) == len(pairs)
paths = [route.path for route in routes]
assert paths.index("/device/address-book/call") < paths.index("/device/address-book/{mac_address}")
assert paths.index("/otaMag/getDownloadUrl/{ota_id}") < paths.index("/otaMag/{ota_id}")
def test_device_update_validation_matches_java_utf16_and_localized_constraints() -> None:
assert _validate_device_update(DeviceUpdateRequest(alias="😀" * 32), "en-US") is None
assert (
_validate_device_update(DeviceUpdateRequest(alias="😀" * 33), "en-US")
== "size must be between 0 and 64"
)
assert _validate_device_update(DeviceUpdateRequest(auto_update=2), "de-DE") == "muss kleiner-gleich 1 sein"
@pytest.mark.asyncio
async def test_unbind_empty_object_is_java_successful_noop_and_alias_validates_target_first() -> None:
async with sqlite_session(DEVICE_SCHEMA) as session:
request = Request({"type": "http", "method": "POST", "path": "/device/unbind", "headers": []})
request.state.user = normal_user()
response = await unbind_device(DeviceUnbindRequest(), request, session)
assert json.loads(response.body) == {"code": 0, "msg": "success", "data": None}
alias_request = Request(
{"type": "http", "method": "PUT", "path": "/device/address-book/alias", "headers": []}
)
alias_request.state.user = normal_user()
alias_response = await update_address_alias(
DeviceAddressBookAliasRequest(), alias_request, session
)
assert json.loads(alias_response.body) == {
"code": 10034,
"msg": "目标MAC地址不能为空",
"data": None,
}
def test_device_list_keeps_java_utc_create_date_but_shanghai_epoch() -> None:
view = DeviceService._user_device_view( # noqa: SLF001 - compatibility regression fixture
{
"id": "device",
"mac_address": "AA:BB:CC:DD:EE:FF",
"create_date": datetime(2026, 7, 20, 12, 34, 56),
}
)
assert view["create_date"] == datetime(2026, 7, 20, 4, 34, 56)
assert view["create_date_timestamp"] == 1_784_522_096_000
@pytest.mark.asyncio
async def test_mqtt_gateway_uses_java_daily_tokens_and_only_retries_401() -> None:
fixed = datetime(2026, 7, 20, 1, 2, 3, tzinfo=timezone.utc)
tokens = daily_authorization_tokens("shared-key", fixed)
seen: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
seen.append(request.headers["Authorization"])
assert json.loads(request.content) == {"clientIds": ["one"]}
return httpx.Response(401 if len(seen) < 3 else 200, text='{"success":true}')
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
result = await post_json(
"http://gateway/api/devices/status",
{"clientIds": ["one"]},
"shared-key",
timeout_seconds=1,
now=fixed,
client=client,
)
assert result == '{"success":true}'
assert seen == [f"Bearer {token}" for token in tokens]
@pytest.mark.asyncio
async def test_device_activation_commits_row_and_consumes_java_redis_keys() -> None:
fake = cast(Redis, FakeRedis())
device_id = "AA:BB:CC:DD:EE:FF"
code = "123456"
await redis_set(f"ota:activation:code:{code}", device_id, client=fake)
await redis_set(
"ota:activation:data:aa_bb_cc_dd_ee_ff",
{
"activation_code": code,
"mac_address": device_id,
"board": "esp32-s3",
"app_version": "1.2.3",
},
client=fake,
)
async with sqlite_session(DEVICE_SCHEMA) as session:
service = DeviceService(session, redis_client=fake)
await service.activate_bound_device(agent_id="agent-1", activation_code=code, user=normal_user())
row = (
await session.execute(text("SELECT * FROM ai_device WHERE id = :id"), {"id": device_id})
).mappings().one()
assert row["user_id"] == 7
assert row["agent_id"] == "agent-1"
assert row["auto_update"] == 1
assert await redis_get(f"ota:activation:code:{code}", fake) is None
assert await redis_get("ota:activation:data:aa_bb_cc_dd_ee_ff", fake) is None
@pytest.mark.asyncio
async def test_manual_add_preserves_java_uuid_fill_for_missing_mac() -> None:
fake = cast(Redis, FakeRedis())
await redis_set("agent:device:count:null", 4, client=fake)
async with sqlite_session(DEVICE_SCHEMA) as session:
await DeviceService(session, redis_client=fake).manual_add(
request=DeviceManualAddRequest(),
user=normal_user(),
)
row = (await session.execute(text("SELECT id,mac_address FROM ai_device"))).mappings().one()
assert len(row["id"]) == 32
assert row["mac_address"] is None
assert await redis_get("agent:device:count:null", fake) is None
@pytest.mark.asyncio
async def test_domain_redis_maps_use_spring_json_type_metadata() -> None:
fake = cast(Redis, FakeRedis())
await redis_set("map", {"outer": {"value": "ok"}}, client=fake)
raw = await cast(Any, fake).get("map")
assert json.loads(raw) == {
"@class": "java.util.HashMap",
"outer": {"@class": "java.util.HashMap", "value": "ok"},
}
assert await redis_get("map", fake) == {"outer": {"value": "ok"}}
@pytest.mark.asyncio
async def test_ota_report_keeps_numeric_timestamp_signatures_and_activation_ttl(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake = cast(Redis, FakeRedis())
params = {
"server.websocket": "ws://one/xiaozhi/v1/",
"server.auth.enabled": "true",
"server.secret": "ws-secret",
"server.mqtt_gateway": "mqtt.example:1883",
"server.mqtt_signature_key": "mqtt-secret",
"server.fronted_url": "https://console.example",
}
async def get_value(_: SystemParamService, code: str, *, from_cache: bool = True) -> str | None:
del from_cache
return params.get(code)
monkeypatch.setattr(SystemParamService, "get_value", get_value)
report = DeviceReportRequest(
application=ApplicationInfo(version="1.0.0"),
board=BoardInfo(type="esp32-s3"),
)
async with sqlite_session(DEVICE_SCHEMA) as session:
payload = await DeviceService(session, redis_client=fake).check_ota(
device_id="AA:BB:CC:DD:EE:FF",
client_id="client-one",
report=report,
request_url="http://manager/xiaozhi/ota/",
client_ip="127.0.0.1",
)
assert isinstance(payload["server_time"]["timestamp"], int)
assert payload["firmware"]["url"].endswith("NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL")
assert payload["activation"]["challenge"] == "AA:BB:CC:DD:EE:FF"
websocket_signature, websocket_timestamp = payload["websocket"]["token"].split(".")
assert websocket_signature
assert websocket_timestamp.isdigit()
mqtt = payload["mqtt"]
decoded_user = json.loads(__import__("base64").b64decode(mqtt["username"]))
assert decoded_user == {"ip": "127.0.0.1"}
assert mqtt["client_id"] == "GID_default@@@AA_BB_CC_DD_EE_FF@@@AA_BB_CC_DD_EE_FF"
activation_key = "ota:activation:data:aa_bb_cc_dd_ee_ff"
assert 86_390 <= await cast(Any, fake).ttl(activation_key) <= 86_400
@pytest.mark.asyncio
async def test_address_book_lookup_matches_historical_server_consumer_contract() -> None:
fake = cast(Redis, FakeRedis())
now = "2026-07-20 10:00:00"
async with sqlite_session(DEVICE_SCHEMA) as session:
await session.execute(
text(
"INSERT INTO ai_device_address_book "
"(mac_address,target_mac,alias,has_permission,create_date,update_date) "
"VALUES (:a,:b,'小明',1,:now,:now),(:b,:a,'客厅',1,:now,:now)"
),
{"a": "AA:AA:AA:AA:AA:AA", "b": "BB:BB:BB:BB:BB:BB", "now": now},
)
await session.commit()
service = DeviceService(session, redis_client=fake)
await service.refresh_address_book_cache()
result = await service.lookup_address_book(caller_mac="AA:AA:AA:AA:AA:AA", nickname="小明")
assert result == {
"targetMac": "bb:bb:bb:bb:bb:bb",
"callerNickname": "客厅",
"hasPermission": "true",
}
@pytest.mark.asyncio
async def test_firmware_storage_md5_and_three_download_limit(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
fake = cast(Redis, FakeRedis())
content = b"firmware-bytes"
expected = f"uploadfile/{hashlib.md5(content, usedforsecurity=False).hexdigest()}.bin"
async with sqlite_session(DEVICE_SCHEMA) as session:
service = DeviceService(session, redis_client=fake)
stored = await service.save_firmware_file(filename="release.bin", content=content)
assert stored == expected
assert Path(stored).read_bytes() == content
await redis_set("ota:id:download", f"file:{stored}", client=fake)
for _ in range(3):
resolved = await service.resolve_ota_download("download")
assert resolved is not None
assert resolved[0].read_bytes() == content
assert resolved[1] == "assets_1.0.0.bin"
assert await service.resolve_ota_download("download") is None
assert await redis_get("ota:id:download", fake) is None
@pytest.mark.asyncio
async def test_firmware_download_route_preserves_binary_headers(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
fake = cast(Redis, FakeRedis())
firmware = tmp_path / "release.bin"
firmware.write_bytes(b"binary-firmware")
await redis_set("ota:id:route-link", f"file:{firmware}", client=fake)
monkeypatch.setattr("app.services.device.get_redis", lambda: fake)
async with sqlite_session(DEVICE_SCHEMA) as session:
app = FastAPI()
app.include_router(device_router)
async def override_db() -> Any:
yield session
app.dependency_overrides[get_db] = override_db
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
response = await client.get("/otaMag/download/route-link")
assert response.status_code == 200
assert response.content == b"binary-firmware"
assert response.headers["content-type"] == "application/octet-stream"
assert response.headers["content-length"] == str(len(response.content))
assert response.headers["content-disposition"] == 'attachment; filename="assets_1.0.0.bin"'
@@ -0,0 +1,69 @@
from __future__ import annotations
import json
import re
import subprocess
import sys
from collections import Counter
from pathlib import Path
from app.main import app
TARGET_ROOT = Path(__file__).resolve().parents[1]
MANIFEST = TARGET_ROOT / "compatibility" / "java-routes.json"
EXTRACTOR = TARGET_ROOT / "scripts" / "extract_java_routes.py"
PATH_PARAMETER = re.compile(r"\{[^/{}]+\}")
def live_manifest() -> dict[str, object]:
result = subprocess.run( # noqa: S603
[sys.executable, str(EXTRACTOR)],
check=True,
capture_output=True,
text=True,
)
return json.loads(result.stdout)
def test_checked_in_java_route_manifest_is_current() -> None:
checked_in = json.loads(MANIFEST.read_text(encoding="utf-8"))
assert checked_in == live_manifest()
def test_java_route_inventory_counts_are_closed() -> None:
payload = live_manifest()
routes = payload["routes"]
assert isinstance(routes, list)
assert payload["count"] == 154
assert len({(route["method"], route["path"]) for route in routes}) == 154
assert Counter(route["method"] for route in routes) == {"GET": 65, "POST": 52, "PUT": 23, "DELETE": 14}
assert Counter(route["auth"] for route in routes) == {
"database-token": 133,
"anonymous": 14,
"server-secret": 7,
}
def test_every_java_route_is_registered_by_fastapi() -> None:
routes = live_manifest()["routes"]
assert isinstance(routes, list)
expected = {
(str(route["method"]), PATH_PARAMETER.sub("{}", f"/xiaozhi{route['path']}"))
for route in routes
}
actual = {
(method, PATH_PARAMETER.sub("{}", route.path))
for route in app.routes
for method in getattr(route, "methods", set())
if method in {"GET", "POST", "PUT", "DELETE"}
and route.path.startswith("/xiaozhi")
and not route.path.startswith("/xiaozhi/health")
and route.path not in {"/xiaozhi/doc.html", "/xiaozhi/v3/api-docs"}
}
consumer_only = {
("GET", "/xiaozhi/api/ping"),
("GET", "/xiaozhi/device/address-book/lookup"),
("PUT", "/xiaozhi/user/configDevice/{}"),
}
assert expected <= actual
assert actual - expected == consumer_only
@@ -0,0 +1,447 @@
from __future__ import annotations
import io
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
import respx
from sqlalchemy import text
from starlette.datastructures import Headers, UploadFile
from starlette.requests import Request
from app.core.errors import AppError
from app.core.responses import ok
from app.core.security import AuthUser
from app.integrations.ragflow import RAGFlowClient
from app.repositories.knowledge import KnowledgeRepository
from app.routers.knowledge import parse_documents
from app.schemas.knowledge import DocumentBatchBody, KnowledgeBaseBody, RetrievalBody
from app.services.knowledge import (
KnowledgeBaseService,
KnowledgeDocumentService,
_document_delete_error,
dataset_dto,
)
from tests.domain_support import FakeRedis, sqlite_session
USER = AuthUser(id=7, username="alice", super_admin=0, status=1, token=str(7), row={})
KNOWLEDGE_SCHEMA = [
"CREATE TABLE ai_rag_dataset (id TEXT PRIMARY KEY,dataset_id TEXT,rag_model_id TEXT,tenant_id TEXT,name TEXT,"
"avatar TEXT,description TEXT,embedding_model TEXT,permission TEXT,chunk_method TEXT,parser_config TEXT,"
"chunk_count INTEGER,document_count INTEGER,token_num INTEGER,status INTEGER,creator INTEGER,created_at DATETIME,"
"updater INTEGER,updated_at DATETIME)",
]
def test_ragflow_configuration_timeout_and_adapter_validation() -> None:
assert RAGFlowClient({"base_url": "https://rag.test", "api_key": "key"}).timeout == 30.0
assert RAGFlowClient(
{"base_url": "https://rag.test", "api_key": "key", "timeout": "invalid"}
).timeout == 30.0
assert RAGFlowClient(
{"base_url": "https://rag.test", "api_key": "key", "timeout": "11"}
).timeout == 11.0
for config, code in (
({}, 10164),
({"api_key": "key"}, 10171),
({"base_url": "https://rag.test"}, 10172),
({"base_url": "rag.test", "api_key": "key"}, 10174),
({"type": "other", "base_url": "https://rag.test", "api_key": "key"}, 10184),
):
with pytest.raises(AppError) as caught:
RAGFlowClient(config)
assert caught.value.code == code
@pytest.mark.asyncio
async def test_ragflow_json_wire_headers_query_and_error_mapping() -> None:
client = RAGFlowClient({"base_url": "https://rag.test", "api_key": "secret"})
with respx.mock(assert_all_called=True) as mock:
route = mock.get("https://rag.test/query").mock(
return_value=httpx.Response(200, json={"code": 0, "data": {}})
)
await client.request("GET", "/query", params={"run": ["DONE", "FAIL"], "enabled": True})
request = route.calls.last.request
assert request.headers["Authorization"] == "Bearer secret"
assert request.headers["Accept-Charset"] == "utf-8"
assert request.url.params["run"] == "[DONE, FAIL]"
assert request.url.params["enabled"] == "true"
with respx.mock:
respx.get("https://rag.test/fail").mock(
return_value=httpx.Response(200, json={"code": 7, "message": "remote failed"})
)
with pytest.raises(AppError) as caught:
await client.request("GET", "/fail")
assert caught.value.code == 10167 and caught.value.params == ("remote failed",)
with respx.mock:
respx.get("https://rag.test/bad-code").mock(
return_value=httpx.Response(200, json={"code": "0", "data": {}})
)
with pytest.raises(AppError) as caught:
await client.request("GET", "/bad-code")
assert caught.value.code == 10167
@pytest.mark.asyncio
async def test_ragflow_upload_filters_invalid_chunk_method_and_parser_fields() -> None:
client = RAGFlowClient({"base_url": "https://rag.test", "api_key": "secret"})
upload = UploadFile(
io.BytesIO(b"hello"),
filename="manual.txt",
headers=Headers({"content-type": "text/plain"}),
)
with respx.mock(assert_all_called=True) as mock:
route = mock.post("https://rag.test/api/v1/datasets/dataset/documents").mock(
return_value=httpx.Response(
200,
json={
"code": 0,
"data": [{"id": "document", "name": "manual.txt", "run": "UNSTART"}],
},
)
)
result = await client.upload_document(
"dataset",
upload,
b"hello",
name="display.txt",
meta_fields={"tag": "测试"},
chunk_method="NOT-A-METHOD",
parser_config={"chunk_token_num": 64, "extra": "drop"},
)
request = route.calls.last.request
multipart = request.content
assert result["id"] == "document"
assert request.headers["Authorization"] == "Bearer secret"
assert "Accept-Charset" not in request.headers
assert b'name="chunk_method"' not in multipart
assert b'"chunk_token_num":64' in multipart
assert b'"delimiter":null' in multipart
assert b'"extra"' not in multipart
assert "测试".encode() in multipart
@pytest.mark.asyncio
async def test_ragflow_dataset_response_uses_strong_info_dto_shape() -> None:
client = RAGFlowClient({"base_url": "https://rag.test", "api_key": "secret"})
with respx.mock(assert_all_called=True) as mock:
mock.post("https://rag.test/api/v1/datasets").mock(
return_value=httpx.Response(
200,
json={
"code": 0,
"data": {
"id": "dataset",
"name": "alice_FAQ",
"chunk_count": "2",
"parser_config": {"chunk_token_num": 64, "unknown": "drop"},
"unknown": "drop",
},
},
)
)
result = await client.create_dataset({"name": "alice_FAQ"})
assert result["chunk_count"] == 2
assert "unknown" not in result
assert "unknown" not in result["parser_config"]
assert result["parser_config"]["delimiter"] is None
@pytest.mark.asyncio
async def test_ragflow_strong_chunk_and_retrieval_response_shapes() -> None:
client = RAGFlowClient({"base_url": "https://rag.test", "api_key": "secret"})
with respx.mock(assert_all_called=True) as mock:
mock.route(method="GET").mock(
return_value=httpx.Response(
200,
json={
"code": 0,
"data": {
"chunks": [{"id": "chunk", "content": "text", "extra": "drop"}],
"doc": {
"id": "doc",
"chunk_count": 2147483648,
"parser_config": {"chunk_token_num": 128, "extra": "drop"},
"run": "DONE",
"extra": "drop",
},
"total": 1,
"extra": "drop",
},
},
)
)
chunks = await client.chunks("ds", "doc", {"page": 1, "page_size": 10})
mock.post("https://rag.test/api/v1/retrieval").mock(
return_value=httpx.Response(
200,
json={
"code": 0,
"data": {
"chunks": [{"id": "hit", "content": "answer", "extra": "drop"}],
"doc_aggs": [{"doc_name": "doc", "doc_id": "id", "count": 2, "extra": 1}],
"total": 1,
"meta_summary": {"total_tokens": 2147483648},
},
},
)
)
retrieval = await client.retrieval({"dataset_ids": ["ds"], "question": "q"})
assert chunks["doc"]["chunk_count"] == "2147483648"
assert chunks["total"] == "1"
assert "extra" not in chunks["doc"] and "extra" not in chunks["chunks"][0]
assert chunks["chunks"][0]["document_id"] is None
assert chunks["doc"]["parser_config"]["delimiter"] is None
assert set(retrieval) == {"chunks", "doc_aggs", "total"}
assert retrieval["total"] == "1"
assert "extra" not in retrieval["chunks"][0] and "extra" not in retrieval["doc_aggs"][0]
assert retrieval["chunks"][0]["document_id"] is None
@pytest.mark.asyncio
async def test_retrieval_service_sends_snake_case_non_null_and_java_bounds() -> None:
repository = SimpleNamespace()
service = KnowledgeDocumentService(repository) # type: ignore[arg-type]
service.datasets.get_owned = AsyncMock(return_value={}) # type: ignore[method-assign]
client = SimpleNamespace(retrieval=AsyncMock(return_value={"chunks": [], "doc_aggs": [], "total": 0}))
service._client_for_dataset = AsyncMock(return_value=client) # type: ignore[method-assign]
await service.retrieval(
"dataset",
RetrievalBody(
question="hello",
page=0,
page_size=0,
top_k=0,
similarity_threshold=-1,
highlight=False,
metadata_condition={"op": "and"},
),
USER,
)
payload = client.retrieval.await_args.args[0]
assert payload == {
"dataset_ids": ["dataset"],
"question": "hello",
"page": 1,
"page_size": 100,
"similarity_threshold": 0.2,
"top_k": 1024,
"highlight": False,
"metadata_condition": {"op": "and"},
}
def test_knowledge_long_null_and_batch_alias_contract() -> None:
dto = dataset_dto(
{
"id": "local",
"chunk_count": 2147483648,
"token_num": 2147483649,
"document_count": 5,
}
)
assert dto["chunkCount"] == "2147483648"
assert dto["tokenNum"] == "2147483649"
assert dto["documentCount"] is None
assert DocumentBatchBody.model_validate({"document_ids": ["a"]}).ids == ["a"]
assert DocumentBatchBody.model_validate({"ids": ["b"]}).ids == ["b"]
assert DocumentBatchBody.model_validate({"documentIds": ["c"]}).ids is None
body = KnowledgeBaseBody.model_validate(
{
"creator": "2147483648",
"createdAt": "2026-07-20 10:00:00",
"updater": "2147483649",
"updatedAt": "2026-07-20 11:00:00",
"documentCount": 2,
"errorMessage": "error",
}
)
assert body.creator == 2147483648 and body.updater == 2147483649
assert body.created_at is not None and body.updated_at is not None
assert body.document_count == 2 and body.error_message == "error"
@pytest.mark.asyncio
async def test_rag_model_config_keeps_java_jsonobject_snake_case_keys() -> None:
repository = SimpleNamespace(
rag_models=AsyncMock(
return_value=[
{
"id": "RAG_RAGFlow",
"model_name": "RAGFlow",
"config_json": '{"type":"ragflow","base_url":"http://localhost","api_key":"secret"}',
}
]
)
)
result = await KnowledgeBaseService(repository).rag_models() # type: ignore[arg-type]
payload = json.loads(ok(result).body)
assert payload["data"][0]["configJson"] == {
"type": "ragflow",
"base_url": "http://localhost",
"api_key": "secret",
}
@pytest.mark.asyncio
async def test_parse_documents_checks_missing_dataset_before_empty_document_ids() -> None:
async with sqlite_session(KNOWLEDGE_SCHEMA) as session:
request = Request(
{
"type": "http",
"method": "POST",
"path": "/datasets/missing/chunks",
"headers": [],
}
)
request.state.user = USER
with pytest.raises(AppError) as caught:
await parse_documents("missing", {}, request, session)
assert caught.value.code == 10163
@pytest.mark.asyncio
async def test_dataset_update_preserves_db_null_columns_but_returns_java_in_memory_nulls(
monkeypatch: pytest.MonkeyPatch,
) -> None:
redis = FakeRedis()
monkeypatch.setattr("app.services.knowledge.get_redis", lambda: redis)
async with sqlite_session(KNOWLEDGE_SCHEMA) as session:
await session.execute(
text(
"INSERT INTO ai_rag_dataset VALUES "
"('local','remote','rag','tenant','FAQ','avatar','desc','embed','me','naive','{}',"
"3,2,10,1,7,'2026-01-01 00:00:00',7,'2026-01-01 00:00:00')"
)
)
await session.commit()
result = await KnowledgeBaseService(KnowledgeRepository(session)).update(
"remote", KnowledgeBaseBody(), USER
)
stored = (
await session.execute(
text(
"SELECT name,avatar,description,embedding_model,permission,chunk_method,creator,created_at "
"FROM ai_rag_dataset WHERE id='local'"
)
)
).one()
assert tuple(stored[:7]) == ("FAQ", "avatar", "desc", "embed", "me", "naive", 7)
assert stored.created_at is not None
assert result["datasetId"] == "remote"
assert result["name"] is None and result["permission"] is None
assert result["creator"] is None and result["createdAt"] is None
assert result["updater"] == USER.id and result["updatedAt"] is not None
@pytest.mark.asyncio
async def test_dataset_update_rejects_dataset_id_conflict_before_remote_call() -> None:
repository = SimpleNamespace(
get_dataset=AsyncMock(return_value={"id": "local", "creator": USER.id, "dataset_id": "old"}),
duplicate_dataset_name=AsyncMock(return_value=False),
dataset_id_conflict=AsyncMock(return_value=True),
)
service = KnowledgeBaseService(repository) # type: ignore[arg-type]
with pytest.raises(AppError) as caught:
await service.update("conflicting", KnowledgeBaseBody(), USER)
assert caught.value.code == 10002
@pytest.mark.asyncio
async def test_dataset_create_generic_db_failure_rolls_back_remote_and_maps_10167() -> None:
session = SimpleNamespace(commit=AsyncMock(), rollback=AsyncMock())
repository = SimpleNamespace(
session=session,
duplicate_dataset_name=AsyncMock(return_value=False),
insert_dataset=AsyncMock(side_effect=RuntimeError("db failed")),
)
client = SimpleNamespace(
create_dataset=AsyncMock(return_value={"id": "remote-id"}),
delete_datasets=AsyncMock(return_value=None),
)
service = KnowledgeBaseService(repository) # type: ignore[arg-type]
service._client = AsyncMock(return_value=client) # type: ignore[method-assign]
with pytest.raises(AppError) as caught:
await service.create(KnowledgeBaseBody(name="FAQ", rag_model_id="rag"), USER)
assert caught.value.code == 10167
assert caught.value.params == ("创建知识库失败: db failed",)
session.rollback.assert_awaited_once()
client.delete_datasets.assert_awaited_once_with(["remote-id"])
@pytest.mark.asyncio
async def test_running_document_missing_remotely_is_cancelled_idempotently() -> None:
session = SimpleNamespace(commit=AsyncMock(), rollback=AsyncMock())
repository = SimpleNamespace(
session=session,
running_documents=AsyncMock(
return_value=[{"dataset_id": "ds", "document_id": "doc", "token_count": 3}]
),
mark_document_remote_deleted=AsyncMock(return_value=1),
)
client = SimpleNamespace(documents=AsyncMock(return_value=([], 0)))
service = KnowledgeDocumentService(repository) # type: ignore[arg-type]
service._client_for_dataset = AsyncMock(return_value=client) # type: ignore[method-assign]
assert await service.sync_running() == 1
repository.mark_document_remote_deleted.assert_awaited_once()
session.commit.assert_awaited_once()
session.rollback.assert_not_awaited()
@pytest.mark.asyncio
async def test_running_sync_updates_only_java_status_columns_and_token_delta() -> None:
session = SimpleNamespace(commit=AsyncMock(), rollback=AsyncMock())
repository = SimpleNamespace(
session=session,
running_documents=AsyncMock(
return_value=[
{
"dataset_id": "ds",
"document_id": "doc",
"status": "1",
"run": "RUNNING",
"token_count": 3,
}
]
),
sync_running_document=AsyncMock(return_value=1),
update_stats=AsyncMock(return_value=None),
)
remote = {
"id": "doc",
"status": "1",
"run": "DONE",
"token_count": 7,
"name": "must-not-be-written-by-status-sync",
}
client = SimpleNamespace(documents=AsyncMock(return_value=([remote], 1)))
service = KnowledgeDocumentService(repository) # type: ignore[arg-type]
service._client_for_dataset = AsyncMock(return_value=client) # type: ignore[method-assign]
assert await service.sync_running() == 1
synced_remote = repository.sync_running_document.await_args.args[2]
assert synced_remote is remote
repository.update_stats.assert_awaited_once_with("ds", 0, 0, 4)
session.commit.assert_awaited_once()
def test_document_delete_remote_error_is_wrapped_as_java_code_500() -> None:
translated = _document_delete_error(AppError(10167, params=("remote failed",)), "en-US")
plain = _document_delete_error(RuntimeError("network down"), None)
assert translated.code == 500 and translated.message and "remote failed" in translated.message
assert (plain.code, plain.message) == (500, "network down")
@@ -0,0 +1,242 @@
from __future__ import annotations
import json
from datetime import datetime
import pytest
from sqlalchemy import text
from starlette.requests import Request
from starlette.routing import Match
from app.core.errors import AppError
from app.core.security import AuthUser
from app.main import app
from app.repositories.correctword import CorrectWordRepository
from app.repositories.model import ModelRepository
from app.repositories.timbre import TimbreRepository
from app.routers.correctword import _java_urlencode, download_file
from app.schemas.correctword import CorrectWordFileBody
from app.schemas.model import ModelConfigBody, ModelProviderBody
from app.schemas.timbre import TimbreBody
from app.services.correctword import CorrectWordService, file_vo
from app.services.model import ModelProviderService, ModelService, _mask_middle
from app.services.timbre import TimbreService, _details, _validation_message
from tests.domain_support import FakeRedis, sqlite_session
USER = AuthUser(id=2147483648, username="tester", super_admin=1, status=1, token=str(2147483648), row={})
MODEL_SCHEMA = [
"CREATE TABLE ai_model_config (id TEXT PRIMARY KEY, model_type TEXT, model_code TEXT, model_name TEXT, "
"is_default INTEGER DEFAULT 0, is_enabled INTEGER DEFAULT 0, config_json TEXT, doc_link TEXT, remark TEXT, "
"sort INTEGER DEFAULT 0)",
"CREATE TABLE ai_model_provider (id TEXT PRIMARY KEY, model_type TEXT, provider_code TEXT, name TEXT, "
"fields TEXT, sort INTEGER, creator INTEGER, create_date DATETIME, updater INTEGER, update_date DATETIME)",
]
TIMBRE_SCHEMA = [
"CREATE TABLE ai_tts_voice (id TEXT PRIMARY KEY, languages TEXT, name TEXT, remark TEXT, "
"reference_audio TEXT, reference_text TEXT, sort INTEGER, tts_model_id TEXT, tts_voice TEXT, "
"voice_demo TEXT, creator INTEGER, create_date DATETIME, updater INTEGER, update_date DATETIME)",
]
CORRECTWORD_SCHEMA = [
"CREATE TABLE ai_agent_correct_word_file (id TEXT PRIMARY KEY, file_name TEXT, word_count INTEGER, "
"content TEXT, creator INTEGER, created_at DATETIME, updater INTEGER, updated_at DATETIME)",
"CREATE TABLE ai_agent_correct_word_item (id TEXT PRIMARY KEY, file_id TEXT, source_word TEXT, target_word TEXT)",
"CREATE TABLE ai_agent_correct_word_mapping (id TEXT, agent_id TEXT, file_id TEXT)",
]
def test_model_enable_specific_route_precedes_generic_model_edit() -> None:
scope = {"type": "http", "method": "PUT", "path": "/xiaozhi/models/enable/model/0"}
endpoint_name = None
for route in app.routes:
match, _ = route.matches(scope)
if match is Match.FULL:
endpoint_name = route.endpoint.__name__
break
assert endpoint_name == "model_enable"
@pytest.mark.asyncio
async def test_model_edit_preserves_not_null_columns_but_returns_request_nulls(
monkeypatch: pytest.MonkeyPatch,
) -> None:
redis = FakeRedis()
monkeypatch.setattr("app.services.model.get_redis", lambda: redis)
async with sqlite_session(MODEL_SCHEMA) as session:
await session.execute(
text(
"INSERT INTO ai_model_provider VALUES "
"('provider', 'LLM', 'openai', 'OpenAI', '{}', 1, 1, NULL, 1, NULL)"
)
)
await session.execute(
text(
"INSERT INTO ai_model_config VALUES "
"('model', 'LLM', 'openai', 'Original', 0, 1, "
"'{\"api_key\":\"secret-value\",\"model\":\"gpt\"}', 'doc', 'remark', 9)"
)
)
await session.commit()
result = await ModelService(ModelRepository(session)).edit(
"LLM",
"openai",
"model",
ModelConfigBody(config_json={"api_key": "secr****alue", "model": "new"}),
)
stored = (
await session.execute(
text("SELECT model_name,is_enabled,remark,sort,config_json FROM ai_model_config WHERE id='model'")
)
).mappings().one()
assert result["modelName"] is None and result["isEnabled"] is None and result["sort"] is None
assert stored["model_name"] == "Original" and stored["is_enabled"] == 1
assert stored["remark"] == "remark" and stored["sort"] == 9
assert json.loads(stored["config_json"]) == {"api_key": "secret-value", "model": "new"}
assert _mask_middle(" ") == " "
assert "model:data:model" not in redis.values
@pytest.mark.asyncio
async def test_model_insert_defaults_and_provider_generated_id_response_match_java() -> None:
async with sqlite_session(MODEL_SCHEMA) as session:
await session.execute(
text(
"INSERT INTO ai_model_provider VALUES "
"('provider', 'ASR', 'mock', 'Mock', '{}', 1, 1, NULL, 1, NULL)"
)
)
await session.commit()
model = await ModelService(ModelRepository(session)).add(
"ASR", "mock", ModelConfigBody(model_code="mock", model_name="ASR")
)
stored_defaults = (
await session.execute(text("SELECT is_enabled,sort FROM ai_model_config WHERE id=:id"), {"id": model["id"]})
).one()
# The assertion query starts its own read transaction; close it before
# exercising the next independently scoped service call.
await session.commit()
provider = await ModelProviderService(ModelRepository(session)).add(
ModelProviderBody(
model_type="TTS", provider_code="new-provider", name="New", fields="not-json", sort=2
),
USER,
)
assert model["isEnabled"] is None and model["sort"] is None
assert tuple(stored_defaults) == (0, 0)
assert provider["id"] is None
provider_count = await session.scalar(
text("SELECT COUNT(*) FROM ai_model_provider WHERE provider_code='new-provider'")
)
assert provider_count == 1
def test_timbre_validation_is_localized_code_500_and_long_sort_is_string() -> None:
with pytest.raises(AppError) as caught:
TimbreService._validate(TimbreBody(), "en-US")
assert caught.value.code == 500
assert caught.value.message == _validation_message("timbre.languages.require", "en-US")
assert _details({"sort": 2147483648})["sort"] == "2147483648"
assert _details({"sort": None})["sort"] == "0"
@pytest.mark.asyncio
async def test_timbre_update_preserves_nullable_columns_and_clears_java_cache_key(
monkeypatch: pytest.MonkeyPatch,
) -> None:
redis = FakeRedis()
await redis.set("timbre:details:voice", b"cached")
monkeypatch.setattr("app.services.timbre.get_redis", lambda: redis)
async with sqlite_session(TIMBRE_SCHEMA) as session:
await session.execute(
text(
"INSERT INTO ai_tts_voice VALUES "
"('voice','zh','Old','remark','ref.wav','hello',7,'tts','old-code','demo.wav',1,NULL,NULL,NULL)"
)
)
await session.commit()
await TimbreService(TimbreRepository(session)).update(
"voice",
TimbreBody(languages="en", name="New", tts_model_id="tts", tts_voice="new-code", sort=None),
USER,
"en-US",
)
row = (
await session.execute(
text(
"SELECT languages,name,remark,reference_audio,reference_text,sort,tts_voice,voice_demo "
"FROM ai_tts_voice WHERE id='voice'"
)
)
).one()
assert tuple(row) == ("en", "New", "remark", "ref.wav", "hello", 0, "new-code", "demo.wav")
assert await redis.get("timbre:details:voice") is None
def test_correctword_validation_size_and_java_line_semantics() -> None:
with pytest.raises(AppError) as missing:
CorrectWordService.validate(CorrectWordFileBody(), check_size=True)
assert (missing.value.code, missing.value.message) == (10034, "文件名不能为空")
oversized = CorrectWordFileBody(file_name="words.txt", content=["a|b"], file_size=1024 * 1024 + 1)
with pytest.raises(AppError) as too_large:
CorrectWordService.validate(oversized, check_size=True)
assert too_large.value.code == 10204
CorrectWordService.validate(oversized, check_size=False)
assert file_vo({"content": ""})["content"] == [""]
assert file_vo({"content": "a|b\n"})["content"] == ["a|b"]
@pytest.mark.asyncio
async def test_correctword_empty_download_and_java_content_disposition() -> None:
async with sqlite_session(CORRECTWORD_SCHEMA) as session:
await session.execute(
text(
"INSERT INTO ai_agent_correct_word_file VALUES "
"('file','中~* a.txt',0,'',2147483648,:now,NULL,NULL)"
),
{"now": datetime(2026, 7, 20, 10, 0, 0)},
)
await session.commit()
request = Request({"type": "http", "method": "GET", "path": "/", "headers": []})
request.state.user = USER
response = await download_file("file", request, session)
assert response.status_code == 200 and response.body == b""
assert response.media_type == "application/octet-stream"
assert response.headers["content-length"] == "0"
assert response.headers["content-disposition"] == (
"attachment; filename=\"_~* a.txt\"; filename*=UTF-8''%E4%B8%AD%7E*%20a.txt"
)
assert _java_urlencode("中~* a.txt") == "%E4%B8%AD%7E*%20a.txt"
@pytest.mark.asyncio
async def test_correctword_update_accepts_large_declared_size() -> None:
async with sqlite_session(CORRECTWORD_SCHEMA) as session:
await session.execute(
text(
"INSERT INTO ai_agent_correct_word_file VALUES "
"('file','old.txt',1,'a|b',2147483648,NULL,NULL,NULL)"
)
)
await session.execute(text("INSERT INTO ai_agent_correct_word_item VALUES ('item','file','a','b')"))
await session.commit()
await CorrectWordService(CorrectWordRepository(session)).update(
"file",
CorrectWordFileBody(
file_name="new.txt", content=["a|b", "invalid", " c | d "], file_size=2 * 1024 * 1024
),
USER,
)
row = (
await session.execute(
text("SELECT file_name,word_count,content FROM ai_agent_correct_word_file WHERE id='file'")
)
).one()
assert tuple(row) == ("new.txt", 2, "a|b\ninvalid\n c | d ")
assert await session.scalar(text("SELECT COUNT(*) FROM ai_agent_correct_word_item WHERE file_id='file'")) == 2
@@ -0,0 +1,136 @@
from __future__ import annotations
import json
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import pytest
from app.core.redis import JavaRedisCodec
TARGET_ROOT = Path(__file__).resolve().parents[1]
REPOSITORY_ROOT = TARGET_ROOT.parents[1]
JAVA_PROJECT = REPOSITORY_ROOT / "main" / "manager-api"
MAVEN = REPOSITORY_ROOT / ".runtime" / "maven" / "bin" / "mvn"
MAVEN_REPOSITORY = REPOSITORY_ROOT / ".runtime" / "m2"
JAVA = REPOSITORY_ROOT / ".runtime" / "jdk" / "bin"
@pytest.fixture(scope="module")
def java_redis_vectors(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Any]:
work = tmp_path_factory.mktemp("redis-java")
classpath_file = work / "classpath.txt"
subprocess.run( # noqa: S603
[
str(MAVEN),
"-o",
"-q",
f"-Dmaven.repo.local={MAVEN_REPOSITORY}",
"-DskipTests",
"compile",
"dependency:build-classpath",
f"-Dmdep.outputFile={classpath_file}",
],
cwd=JAVA_PROJECT,
check=True,
capture_output=True,
text=True,
)
classpath = f"{JAVA_PROJECT / 'target' / 'classes'}:{classpath_file.read_text(encoding='utf-8').strip()}"
subprocess.run( # noqa: S603
[
str(JAVA / "javac"),
"-cp",
classpath,
"-d",
str(work),
str(TARGET_ROOT / "tests" / "java" / "RedisCodecVectors.java"),
],
check=True,
capture_output=True,
text=True,
)
result = subprocess.run( # noqa: S603
[str(JAVA / "java"), "-cp", f"{work}:{classpath}", "RedisCodecVectors"],
check=True,
capture_output=True,
text=True,
)
vectors: dict[str, Any] = {}
for line in result.stdout.splitlines():
name, wire, _decoded_class = line.split("\t", 2)
vectors[name] = json.loads(wire)
return vectors
def _python_wire(value: Any, **kwargs: Any) -> Any:
return json.loads(JavaRedisCodec.encode(value, **kwargs))
def test_spring_redis_serializer_core_wire_format_matches_python(java_redis_vectors: dict[str, Any]) -> None:
assert java_redis_vectors["string"] == _python_wire("hello")
assert java_redis_vectors["integer"] == _python_wire(7)
assert java_redis_vectors["long"] == _python_wire(2_147_483_648)
assert java_redis_vectors["boolean"] == _python_wire(True)
nested = {"enabled": True}
assert java_redis_vectors["map"] == _python_wire(
{
"text": "hello",
"number": 2_147_483_648,
"list": ["a", "b"],
"nested": nested,
}
)
assert java_redis_vectors["list"] == _python_wire(["a", 2_147_483_648, nested])
epoch_in_shanghai = datetime.fromtimestamp(0, ZoneInfo("Asia/Shanghai")).replace(tzinfo=None)
assert java_redis_vectors["date"] == _python_wire(epoch_in_shanghai)
def test_spring_redis_serializer_domain_pojo_wire_format_matches_python(java_redis_vectors: dict[str, Any]) -> None:
assert java_redis_vectors["pojo"] == _python_wire(
{
"id": "voice-1",
"languages": None,
"name": "sample",
"remark": None,
"reference_audio": None,
"reference_text": None,
"sort": 2,
"tts_model_id": None,
"tts_voice": None,
"voice_demo": None,
},
java_type="xiaozhi.modules.timbre.vo.TimbreDetailsVO",
field_java_types={"sort": "java.lang.Long"},
)
assert java_redis_vectors["model-pojo"] == _python_wire(
{
"id": "model-1",
"model_type": "LLM",
"model_code": None,
"model_name": None,
"is_default": None,
"is_enabled": None,
"config_json": {"type": "openai", "api_key": "secret"},
"doc_link": None,
"remark": None,
"sort": None,
"updater": None,
"update_date": None,
"creator": None,
"create_date": None,
},
java_type="xiaozhi.modules.model.entity.ModelConfigEntity",
field_java_types={
"configJson": "cn.hutool.json.JSONObject",
"creator": "java.lang.Long",
"updater": "java.lang.Long",
},
)
assert java_redis_vectors["dict-list"] == _python_wire(
[{"name": "China", "key": "+86"}],
item_java_type="xiaozhi.modules.sys.vo.SysDictDataItem",
)
@@ -0,0 +1,37 @@
from __future__ import annotations
import warnings
from fastapi._compat import get_model_fields
from app.schemas.correctword import CorrectWordFileBody
from app.schemas.device import DeviceReportRequest, DeviceUpdateRequest
from app.schemas.model import ModelProviderBody
def test_fastapi_can_rebuild_all_alias_fields_without_ineffective_metadata_warnings() -> None:
# FastAPI reconstructs each body model field through TypeAdapter on first
# request. Keep this explicit regression because an unconstrained future
# Pydantic upgrade emitted warnings for every aliased field on that path.
with warnings.catch_warnings():
warnings.simplefilter("error")
for model in (DeviceUpdateRequest, ModelProviderBody, DeviceReportRequest, CorrectWordFileBody):
assert get_model_fields(model)
def test_representative_java_aliases_bind_and_serialize_in_both_supported_input_forms() -> None:
update = DeviceUpdateRequest.model_validate({"autoUpdate": 1})
assert update.auto_update == 1
assert update.model_dump(by_alias=True)["autoUpdate"] == 1
provider = ModelProviderBody.model_validate({"modelType": "LLM", "providerCode": "mock"})
assert (provider.model_type, provider.provider_code) == ("LLM", "mock")
camel_report = DeviceReportRequest.model_validate({"flashSize": 8, "chipModelName": "esp32"})
snake_report = DeviceReportRequest.model_validate({"flash_size": 8, "chip_model_name": "esp32"})
assert camel_report.flash_size == snake_report.flash_size == 8
assert camel_report.chip_model_name == snake_report.chip_model_name == "esp32"
words = CorrectWordFileBody.model_validate({"fileName": "words.txt", "fileSize": 12})
assert words.file_name == "words.txt"
assert words.file_size == 12
@@ -0,0 +1,239 @@
from __future__ import annotations
import json
import urllib.parse
from datetime import datetime
from pathlib import Path
from typing import Any
import httpx
import pytest
from fastapi import Request
from sqlalchemy import text
from app.core.crypto import bcrypt_hash, sm2_encrypt_c1c3c2
from app.core.errors import AppError
from app.core.redis import JavaRedisCodec
from app.core.security import AuthUser
from app.repositories.security import SecurityRepository
from app.routers.security import security_router
from app.schemas.security import (
LoginRequest,
PasswordChangeRequest,
RetrievePasswordRequest,
SmsVerificationRequest,
)
from app.services.security import AliyunSmsSender, CaptchaService, SecurityService
from tests.domain_support import FakeRedis, sqlite_session
SECURITY_SCHEMA = [
"CREATE TABLE sys_params (param_code TEXT PRIMARY KEY, param_value TEXT)",
"CREATE TABLE sys_user ("
"id INTEGER PRIMARY KEY, username TEXT UNIQUE, password TEXT, super_admin INTEGER, status INTEGER, "
"creator INTEGER, create_date DATETIME, updater INTEGER, update_date DATETIME)",
"CREATE TABLE sys_user_token ("
"id INTEGER PRIMARY KEY, user_id INTEGER UNIQUE, token TEXT, expire_date DATETIME, "
"update_date DATETIME, create_date DATETIME)",
"CREATE TABLE sys_dict_type (id INTEGER PRIMARY KEY, dict_type TEXT)",
"CREATE TABLE sys_dict_data ("
"id INTEGER PRIMARY KEY, dict_type_id INTEGER, dict_label TEXT, dict_value TEXT, sort INTEGER)",
]
SM2_VECTOR = json.loads((Path(__file__).parent / "fixtures" / "sm2-c1c3c2-golden.json").read_text(encoding="utf-8"))
def _request() -> Request:
return Request(
{
"type": "http",
"method": "POST",
"path": "/xiaozhi/user/login",
"headers": [(b"user-agent", b"Manager-Web"), (b"x-forwarded-for", b"192.0.2.10")],
"client": ("127.0.0.1", 52341),
}
)
@pytest.mark.asyncio
async def test_sm2_login_reuses_token_and_password_change_expires_it() -> None:
public_key, private_key = SM2_VECTOR["publicKey"], SM2_VECTOR["privateKey"]
redis = FakeRedis()
async with sqlite_session(SECURITY_SCHEMA) as session:
await session.execute(
text("INSERT INTO sys_params VALUES ('server.private_key', :private_key)"),
{"private_key": private_key},
)
await session.execute(
text(
"INSERT INTO sys_user "
"(id, username, password, super_admin, status) VALUES (1, 'alice', :password, 1, 1)"
),
{"password": bcrypt_hash("StrongPass1", rounds=4)},
)
await session.commit()
captcha = CaptchaService(redis) # type: ignore[arg-type]
service = SecurityService(
SecurityRepository(session),
redis=redis, # type: ignore[arg-type]
captcha=captcha,
)
await redis.set("sys:captcha:first", JavaRedisCodec.encode("Ab12C"), ex=300)
first = await service.login(
LoginRequest(
username="alice",
password=sm2_encrypt_c1c3c2(public_key, "Ab12CStrongPass1"),
captcha_id="first",
),
_request(),
)
assert len(first["token"]) == 32
assert len(first["clientHash"]) == 32
assert await redis.get("sys:captcha:first") is None
await redis.set("sys:captcha:second", JavaRedisCodec.encode("Z9x8Y"), ex=300)
second = await service.login(
LoginRequest(
username="alice",
password=sm2_encrypt_c1c3c2(public_key, "Z9x8YStrongPass1"),
captcha_id="second",
),
_request(),
)
assert second["token"] == first["token"]
auth = AuthUser(1, "alice", 1, 1, first["token"], {})
await service.change_password(
auth,
PasswordChangeRequest(password="StrongPass1", new_password="NewStrong2"), # noqa: S106
)
expiry = await session.scalar(text("SELECT expire_date FROM sys_user_token WHERE user_id = 1"))
assert expiry is not None
parsed_expiry = datetime.fromisoformat(expiry) if isinstance(expiry, str) else expiry
assert parsed_expiry < datetime.now()
await redis.set("sys:captcha:bad", JavaRedisCodec.encode("right"), ex=300)
with pytest.raises(AppError) as captured:
await service.login(
LoginRequest(
username="alice",
password=sm2_encrypt_c1c3c2(public_key, "wrongNewStrong2"),
captcha_id="bad",
),
_request(),
)
assert captured.value.code == 10067
assert await redis.get("sys:captcha:bad") is None
with pytest.raises(AppError) as missing_password:
await service.login(LoginRequest(username="alice"), _request())
assert missing_password.value.code == 10130
with pytest.raises(AppError) as manual_validation:
await service.change_password(auth, PasswordChangeRequest(), "en-US")
assert manual_validation.value.code == 500
assert manual_validation.value.message == "The password cannot be empty"
await session.execute(
text(
"INSERT INTO sys_params VALUES "
"('server.enable_mobile_register', 'true'), ('server.public_key', 'unused')"
)
)
await session.commit()
with pytest.raises(AppError) as retrieve_validation:
await service.retrieve_password(RetrievePasswordRequest(), "de-DE")
assert retrieve_validation.value.code == 500
assert retrieve_validation.value.message == "Das Passwort darf nicht leer sein"
class _RecordingSmsSender:
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = []
async def send_verification_code(self, phone: str | None, code: str) -> None:
assert phone is not None
self.calls.append((phone, code))
@pytest.mark.asyncio
async def test_sms_rate_limit_cache_ttl_and_aliyun_rpc_shape() -> None:
redis = FakeRedis()
sender = _RecordingSmsSender()
async with sqlite_session(SECURITY_SCHEMA) as session:
await session.execute(
text(
"INSERT INTO sys_params VALUES "
"('server.enable_mobile_register', 'true'),"
"('server.sms_max_send_count', '2'),"
"('aliyun.sms.access_key_id', 'test-id'),"
"('aliyun.sms.access_key_secret', 'test-secret'),"
"('aliyun.sms.sign_name', 'test-sign'),"
"('aliyun.sms.sms_code_template_code', 'SMS_123')"
)
)
await session.commit()
await redis.set("sys:captcha:sms-flow", JavaRedisCodec.encode("A1b2C"), ex=300)
service = SecurityService(
SecurityRepository(session),
redis=redis, # type: ignore[arg-type]
captcha=CaptchaService(redis), # type: ignore[arg-type]
sms_sender=sender,
)
with pytest.raises(AppError) as missing_sms_fields:
await service.send_sms_verification(SmsVerificationRequest())
assert missing_sms_fields.value.code == 10067
dto = SmsVerificationRequest(phone="+8613800138000", captcha="a1B2c", captcha_id="sms-flow")
await service.send_sms_verification(dto)
assert len(sender.calls) == 1
assert sender.calls[0][0] == "+8613800138000"
assert sender.calls[0][1].isdigit() and len(sender.calls[0][1]) == 6
cached_code = JavaRedisCodec.decode(await redis.get("sys:captcha:sms:Validate:Code:+8613800138000"))
assert cached_code == sender.calls[0][1]
assert 0 < await redis.ttl("sms:Validate:Code:+8613800138000:today_count") <= 86400
with pytest.raises(AppError) as captured:
await service.send_sms_verification(dto)
assert captured.value.code == 10060
recorded: dict[str, Any] = {}
async def handler(request: httpx.Request) -> httpx.Response:
recorded["params"] = dict(urllib.parse.parse_qsl((await request.aread()).decode()))
return httpx.Response(200, json={"Code": "OK"})
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
aliyun = AliyunSmsSender(
SecurityRepository(session),
redis=redis, # type: ignore[arg-type]
client=client,
)
await aliyun.send_verification_code("+8613800138000", "123456")
params = recorded["params"]
assert params["Action"] == "SendSms"
assert params["TemplateParam"] == '{"code":"123456"}'
assert params["Signature"]
await session.execute(
text(
"UPDATE sys_params SET param_value = '' "
"WHERE param_code IN ('aliyun.sms.access_key_id', 'aliyun.sms.access_key_secret')"
)
)
await session.commit()
blank_credentials = AliyunSmsSender(
SecurityRepository(session),
redis=FakeRedis(), # type: ignore[arg-type]
)
with pytest.raises(AppError) as connection_error:
await blank_credentials.send_verification_code("+8613800138000", "123456")
assert connection_error.value.code == 10056
def test_security_router_exposes_all_login_controller_routes_and_ping() -> None:
routes = {(next(iter(route.methods)), route.path) for route in security_router.routes}
assert len(routes) == 9
assert ("POST", "/user/login") in routes
assert ("GET", "/user/captcha") in routes
assert ("GET", "/api/ping") in routes
@@ -0,0 +1,86 @@
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
from app.core.crypto import sm2_decrypt_c1c3c2, sm2_encrypt_c1c3c2
TARGET_ROOT = Path(__file__).resolve().parents[1]
REPOSITORY_ROOT = TARGET_ROOT.parents[1]
VECTOR_PATH = TARGET_ROOT / "tests" / "fixtures" / "sm2-c1c3c2-golden.json"
BCPROV = (
REPOSITORY_ROOT
/ ".runtime"
/ "m2"
/ "org"
/ "bouncycastle"
/ "bcprov-jdk18on"
/ "1.78"
/ "bcprov-jdk18on-1.78.jar"
)
JAVA_HOME = REPOSITORY_ROOT / ".runtime" / "jdk" / "bin"
@pytest.fixture(scope="module")
def vector() -> dict[str, str]:
return json.loads(VECTOR_PATH.read_text(encoding="utf-8"))
@pytest.fixture(scope="module")
def java_classpath(tmp_path_factory: pytest.TempPathFactory) -> str:
classes = tmp_path_factory.mktemp("sm2-java")
command = [
str(JAVA_HOME / "javac"),
"-cp",
str(BCPROV),
"-d",
str(classes),
str(
REPOSITORY_ROOT
/ "main"
/ "manager-api"
/ "src"
/ "main"
/ "java"
/ "xiaozhi"
/ "common"
/ "utils"
/ "SM2Utils.java"
),
str(TARGET_ROOT / "tests" / "java" / "Sm2InteropCli.java"),
]
subprocess.run(command, check=True, capture_output=True, text=True) # noqa: S603
return f"{classes}:{BCPROV}"
def java_sm2(classpath: str, operation: str, key: str, value: str) -> str:
result = subprocess.run( # noqa: S603
[str(JAVA_HOME / "java"), "-cp", classpath, "Sm2InteropCli", operation, key, value],
check=True,
capture_output=True,
text=True,
)
return result.stdout
def test_static_java_and_python_vectors_decrypt_in_python(vector: dict[str, str]) -> None:
assert sm2_decrypt_c1c3c2(vector["privateKey"], vector["javaCiphertext"]) == vector["plaintext"]
assert sm2_decrypt_c1c3c2(vector["privateKey"], vector["pythonCiphertext"]) == vector["plaintext"]
def test_static_java_and_python_vectors_decrypt_in_java(vector: dict[str, str], java_classpath: str) -> None:
assert java_sm2(java_classpath, "decrypt", vector["privateKey"], vector["javaCiphertext"]) == vector["plaintext"]
assert java_sm2(java_classpath, "decrypt", vector["privateKey"], vector["pythonCiphertext"]) == vector["plaintext"]
def test_fresh_ciphertexts_are_bidirectionally_compatible(vector: dict[str, str], java_classpath: str) -> None:
java_ciphertext = java_sm2(java_classpath, "encrypt", vector["publicKey"], vector["plaintext"])
assert java_ciphertext.startswith("04")
assert sm2_decrypt_c1c3c2(vector["privateKey"], java_ciphertext) == vector["plaintext"]
python_ciphertext = sm2_encrypt_c1c3c2(vector["publicKey"], vector["plaintext"])
assert python_ciphertext.startswith("04")
assert java_sm2(java_classpath, "decrypt", vector["privateKey"], python_ciphertext) == vector["plaintext"]
@@ -0,0 +1,310 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
from typing import Any
import httpx
import pytest
from sqlalchemy import text
from websockets.asyncio.server import serve
from app.core.crypto import bcrypt_matches
from app.core.errors import AppError
from app.core.redis import JavaRedisCodec
from app.core.security import AuthUser
from app.repositories.sys import SysRepository
from app.routers.sys import sys_router
from app.schemas.sys import DictDataPayload, DictTypePayload, EmitServerActionRequest, SysParamPayload
from app.services.sys import AdminService, DictService, ParamExternalValidator, ServerActionService, SysParamService
from tests.domain_support import FakeRedis, sqlite_session
SYS_SCHEMA = [
"CREATE TABLE sys_user ("
"id INTEGER PRIMARY KEY, username TEXT, password TEXT, super_admin INTEGER, status INTEGER, "
"creator INTEGER, create_date DATETIME, updater INTEGER, update_date DATETIME)",
"CREATE TABLE ai_device ("
"id INTEGER PRIMARY KEY, user_id INTEGER, mac_address TEXT, last_connected_at DATETIME, auto_update INTEGER, "
"board TEXT, alias TEXT, agent_id TEXT, app_version TEXT, sort INTEGER, create_date DATETIME, "
"update_date DATETIME)",
"CREATE TABLE sys_params ("
"id INTEGER PRIMARY KEY, param_code TEXT UNIQUE, param_value TEXT, value_type TEXT, param_type INTEGER, "
"remark TEXT, creator INTEGER, create_date DATETIME, updater INTEGER, update_date DATETIME)",
"CREATE TABLE ai_agent_plugin_mapping (id INTEGER PRIMARY KEY, agent_id TEXT, plugin_id TEXT)",
"CREATE TABLE sys_dict_type ("
"id INTEGER PRIMARY KEY, dict_type TEXT UNIQUE, dict_name TEXT, remark TEXT, sort INTEGER, "
"creator INTEGER, create_date DATETIME, updater INTEGER, update_date DATETIME)",
"CREATE TABLE sys_dict_data ("
"id INTEGER PRIMARY KEY, dict_type_id INTEGER, dict_label TEXT, dict_value TEXT, remark TEXT, sort INTEGER, "
"creator INTEGER, create_date DATETIME, updater INTEGER, update_date DATETIME)",
]
ADMIN = AuthUser(7, "root", 1, 1, "token", {})
@pytest.mark.asyncio
async def test_admin_param_update_external_mock_menu_side_effect_and_cache() -> None:
redis = FakeRedis()
calls: list[str] = []
async def endpoint(request: httpx.Request) -> httpx.Response:
calls.append(str(request.url))
return httpx.Response(200, text="xiaozhi OTA service")
async with sqlite_session(SYS_SCHEMA) as session, httpx.AsyncClient(
transport=httpx.MockTransport(endpoint)
) as client:
await session.execute(
text(
"INSERT INTO sys_params "
"(id, param_code, param_value, value_type, param_type) VALUES "
"(1, 'server.ota', 'https://old.example/ota/', 'string', 1),"
"(2, 'system-web.menu', :menu, 'json', 1)"
),
{"menu": json.dumps({"features": {"addressBook": {"enabled": True}}})},
)
await session.execute(
text(
"INSERT INTO ai_agent_plugin_mapping (id, agent_id, plugin_id) "
"VALUES (10, 'agent-1', 'SYSTEM_PLUGIN_CALL_DEVICE')"
)
)
await session.execute(
text(
"INSERT INTO sys_user (id, username, status, super_admin) VALUES "
"(7, 'root', 1, 1), (8, 'member', 1, 0)"
)
)
await session.execute(
text(
"INSERT INTO ai_device "
"(id, user_id, mac_address, board, alias, app_version, create_date, update_date) "
"VALUES (20, 8, 'AA:BB', 'esp32s3', 'Kitchen', '1.0.0', "
"'2026-01-02 03:04:05', CURRENT_TIMESTAMP)"
)
)
await session.commit()
params = SysParamService(
SysRepository(session),
redis=redis, # type: ignore[arg-type]
validator=ParamExternalValidator(client),
)
await params.update(
SysParamPayload(
id=1,
param_code="server.ota",
param_value="https://mock.example/ota/",
value_type="string",
),
ADMIN,
)
assert calls == ["https://mock.example/ota/"]
assert await params.get_value("server.ota") == "https://mock.example/ota/"
assert 0 < await redis.ttl("sys:params") <= 86400
await params.update(
SysParamPayload(
id=2,
param_code="system-web.menu",
param_value=json.dumps({"features": {"addressBook": {"enabled": False}}}),
value_type="json",
),
ADMIN,
)
remaining = await session.scalar(
text("SELECT COUNT(*) FROM ai_agent_plugin_mapping WHERE plugin_id = 'SYSTEM_PLUGIN_CALL_DEVICE'")
)
assert remaining == 0
# Missing `features` does not mean disabled in Java: both feature maps
# must be present before it removes call-device mappings.
await session.execute(
text("UPDATE sys_params SET param_value = :menu WHERE id = 2"),
{"menu": json.dumps({"features": {"addressBook": {"enabled": True}}})},
)
await session.execute(
text(
"INSERT INTO ai_agent_plugin_mapping (id, agent_id, plugin_id) "
"VALUES (11, 'agent-1', 'SYSTEM_PLUGIN_CALL_DEVICE')"
)
)
await session.commit()
await params.update(
SysParamPayload(id=2, param_code="system-web.menu", param_value="{}", value_type="json"),
ADMIN,
)
assert await session.scalar(text("SELECT COUNT(*) FROM ai_agent_plugin_mapping WHERE id = 11")) == 1
with pytest.raises(AppError) as add_group:
await params.save(
SysParamPayload(
id=99,
param_code="new.param",
param_value="value",
value_type="string",
),
ADMIN,
"en-US",
)
assert add_group.value.code == 500
assert add_group.value.message == "ID has to be empty"
with pytest.raises(AppError) as update_group:
await params.update(
SysParamPayload(param_code="new.param", param_value="value", value_type="string"),
ADMIN,
"de-DE",
)
assert update_group.value.code == 500
assert update_group.value.message == "ID darf nicht leer sein"
with pytest.raises(AppError) as value_type_pattern:
await params.update(
SysParamPayload(id=1, param_code="server.ota", param_value="value", value_type="STRING"),
ADMIN,
"en-US",
)
assert value_type_pattern.value.code == 500
assert value_type_pattern.value.message == "Value type must be string, number, boolean or array"
admin = AdminService(SysRepository(session))
users = await admin.page_users(mobile="mem", page=1, limit=10)
assert users["total"] == 1
assert users["list"][0]["deviceCount"] == "1"
zero_limit_users = await admin.page_users(mobile="mem", page=1, limit=0)
assert zero_limit_users == {"list": [], "total": 1}
generated = await admin.reset_password(8, ADMIN)
stored = await session.scalar(text("SELECT password FROM sys_user WHERE id = 8"))
assert len(generated) == 12 and bcrypt_matches(generated, stored)
assert await session.scalar(text("SELECT updater FROM sys_user WHERE id = 8")) == ADMIN.id
devices = await admin.page_devices(keywords="Kit", page=1, limit=10)
assert devices["list"][0]["createDate"] == "2026-01-01 19:04:05"
with pytest.raises(ValueError):
await admin.change_status(0, ["8", "not-a-long"], ADMIN)
assert await session.scalar(text("SELECT status FROM sys_user WHERE id = 8")) == 1
@pytest.mark.asyncio
async def test_dict_crud_preserves_java_duplicate_rule_and_invalidates_cache() -> None:
redis = FakeRedis()
async with sqlite_session(SYS_SCHEMA) as session:
await session.execute(text("INSERT INTO sys_user (id, username, status, super_admin) VALUES (7, 'root', 1, 1)"))
await session.execute(
text(
"INSERT INTO sys_dict_type "
"(id, dict_type, dict_name, sort, creator, updater) VALUES (1, 'MOBILE_AREA', 'Area', 1, 7, 7)"
)
)
await session.commit()
service = DictService(SysRepository(session), redis=redis) # type: ignore[arg-type]
await service.save_type(
DictTypePayload(id=99, dict_type="CUSTOM", dict_name="Custom", sort=-1),
ADMIN,
)
custom_sort = await session.scalar(text("SELECT sort FROM sys_dict_type WHERE id = 99"))
assert custom_sort == -1
await service.save_data(
DictDataPayload(dict_type_id=1, dict_label="China", dict_value="+86", sort=1),
ADMIN,
)
row = (await session.execute(text("SELECT id FROM sys_dict_data"))).scalar_one()
assert await service.items("MOBILE_AREA") == [{"name": "China", "key": "+86"}]
cached_items = json.loads((await redis.get("sys:dict:data:MOBILE_AREA")).decode())
assert cached_items[0] == "java.util.ArrayList"
assert cached_items[1][0]["@class"] == "xiaozhi.modules.sys.vo.SysDictDataItem"
await service.update_data(DictDataPayload(id=row, dict_value="+1"), ADMIN)
partially_updated = (
await session.execute(
text("SELECT dict_type_id, dict_label, dict_value FROM sys_dict_data WHERE id = :id"),
{"id": row},
)
).mappings().one()
assert dict(partially_updated) == {"dict_type_id": 1, "dict_label": "China", "dict_value": "+1"}
# Java cannot derive the real cache key without dictTypeId and therefore leaves this stale entry in place.
assert await service.items("MOBILE_AREA") == [{"name": "China", "key": "+86"}]
await service.update_data(
DictDataPayload(id=row, dict_type_id=1, dict_label="China mainland", dict_value="+86", sort=2),
ADMIN,
)
assert await redis.get("sys:dict:data:MOBILE_AREA") is None
# SysDictDataServiceImpl compares dictValue to the existing dictLabel; this intentionally tests that quirk.
with pytest.raises(AppError) as captured:
await service.save_data(
DictDataPayload(dict_type_id=1, dict_label="Unrelated", dict_value="China mainland", sort=3),
ADMIN,
)
assert captured.value.code == 10128
@pytest.mark.asyncio
async def test_server_action_websocket_hmac_headers_payload_and_one_time_registration() -> None:
redis = FakeRedis()
captured: dict[str, Any] = {}
async def handler(websocket: Any) -> None:
captured["headers"] = dict(websocket.request.headers)
captured["payload"] = json.loads(await websocket.recv())
await websocket.send(json.dumps({"status": "success", "type": "server", "content": {"action": "done"}}))
async with serve(handler, "127.0.0.1", 0) as server:
socket = server.sockets[0]
port = socket.getsockname()[1]
websocket_url = f"ws://127.0.0.1:{port}/xiaozhi/v1/"
async with sqlite_session(SYS_SCHEMA) as session:
await session.execute(
text(
"INSERT INTO sys_params "
"(id, param_code, param_value, value_type, param_type) VALUES "
"(1, 'server.websocket', :url, 'string', 1),"
"(2, 'server.secret', 'local-test-secret', 'string', 1)"
),
{"url": websocket_url + ";;"},
)
await session.commit()
param_service = SysParamService(SysRepository(session), redis=redis) # type: ignore[arg-type]
assert await ServerActionService(param_service, redis=redis).server_list() == [websocket_url] # type: ignore[arg-type]
result = await ServerActionService(param_service, redis=redis).emit( # type: ignore[arg-type]
EmitServerActionRequest(target_ws=websocket_url, action="RESTART")
)
assert result is True
headers = captured["headers"]
device_id = headers["device-id"]
client_id = headers["client-id"]
token, timestamp_text = headers["authorization"].removeprefix("Bearer ").rsplit(".", 1)
expected = hmac.new(
b"local-test-secret",
f"{client_id}|{device_id}|{timestamp_text}".encode(),
digestmod=hashlib.sha256,
).digest()
assert token == base64.urlsafe_b64encode(expected).rstrip(b"=").decode()
assert captured["payload"] == {
"type": "server",
"action": "restart",
"content": {"secret": "local-test-secret"},
}
assert JavaRedisCodec.decode(await redis.get(f"tmp_register_mac:{device_id}")) == "true"
assert 0 < await redis.ttl(f"tmp_register_mac:{device_id}") <= 300
with pytest.raises(AppError) as empty_urls:
await ParamExternalValidator().validate("server.websocket", ";")
assert empty_urls.value.code == 10098
def test_sys_router_exposes_all_seven_controller_groups() -> None:
routes = {(method, route.path) for route in sys_router.routes for method in route.methods}
assert len(routes) == 23
assert ("GET", "/admin/users") in routes
assert ("POST", "/admin/server/emit-action") in routes
assert ("POST", "/admin/params/delete") in routes
assert ("GET", "/admin/dict/data/type/{dict_type}") in routes
@@ -0,0 +1,234 @@
from __future__ import annotations
import base64
import json
from typing import Any, cast
import httpx
import pytest
from fastapi import FastAPI
from redis.asyncio import Redis
from sqlalchemy import text
from app.core.database import get_db
from app.core.errors import AppError
from app.core.security import AuthUser
from app.integrations.voice_clone import VoiceCloneIntegration
from app.routers.voiceclone import voiceclone_router
from app.schemas.voiceclone import VoiceResourceCreateRequest
from app.services.voiceclone import VoiceCloneService
from tests.domain_support import FakeRedis, sqlite_session
VOICE_SCHEMA = [
"""
CREATE TABLE ai_voice_clone (
id VARCHAR(32) PRIMARY KEY, name VARCHAR(64), model_id VARCHAR(32), voice_id VARCHAR(32),
languages VARCHAR(50), user_id BIGINT, voice BLOB, train_status INTEGER, train_error VARCHAR(255),
creator BIGINT, create_date DATETIME
)
""",
"""
CREATE TABLE ai_model_config (
id VARCHAR(32) PRIMARY KEY, model_type VARCHAR(20), model_name VARCHAR(50), config_json JSON
)
""",
"CREATE TABLE sys_user (id BIGINT PRIMARY KEY, username VARCHAR(50))",
]
def user(user_id: int = 8, *, super_admin: int = 0) -> AuthUser:
return AuthUser(
id=user_id,
username="voice-user",
super_admin=super_admin,
status=1,
token="test-token", # noqa: S106 - isolated authentication fixture
row={"id": user_id},
)
def test_voiceclone_router_closes_twelve_paths_with_static_paths_first() -> None:
routes = [route for route in voiceclone_router.routes if hasattr(route, "methods")]
pairs = {(next(iter(route.methods)), route.path) for route in routes}
assert pairs == {
("GET", "/voiceResource/ttsPlatforms"),
("GET", "/voiceResource/user/{user_id}"),
("GET", "/voiceResource"),
("GET", "/voiceResource/{voice_id}"),
("POST", "/voiceResource"),
("DELETE", "/voiceResource/{voice_id}"),
("GET", "/voiceClone"),
("POST", "/voiceClone/upload"),
("POST", "/voiceClone/updateName"),
("POST", "/voiceClone/audio/{voice_id}"),
("GET", "/voiceClone/play/{download_id}"),
("POST", "/voiceClone/cloneAudio"),
}
assert len(routes) == len(pairs)
paths = [route.path for route in routes]
assert paths.index("/voiceResource/ttsPlatforms") < paths.index("/voiceResource/{voice_id}")
assert paths.index("/voiceResource/user/{user_id}") < paths.index("/voiceResource/{voice_id}")
@pytest.mark.asyncio
async def test_voice_resource_create_page_upload_and_one_time_audio() -> None:
fake = cast(Redis, FakeRedis())
async with sqlite_session(VOICE_SCHEMA) as session:
await session.execute(text("INSERT INTO sys_user VALUES (8, 'voice-user')"))
await session.execute(
text(
"INSERT INTO ai_model_config (id,model_type,model_name,config_json) "
"VALUES ('tts-1','TTS','火山双向流',:config)"
),
{"config": json.dumps({"type": "huoshan_double_stream", "appid": "app", "access_token": "token"})},
)
await session.commit()
service = VoiceCloneService(session, redis_client=fake)
await service.create_resources(
VoiceResourceCreateRequest(
model_id="tts-1",
voice_ids=["S_voice_one"],
user_id=8,
languages="zh-CN",
),
actor=user(super_admin=1),
)
page = await service.page({"page": "1", "limit": "10"}, user_id=8)
assert page["total"] == 1
item = page["list"][0]
assert item["model_name"] == "火山双向流"
assert item["user_name"] == "voice-user"
assert item["has_voice"] is False
voice_id = str(item["id"])
await service.check_permission(voice_id, user())
await service.upload_voice(voice_id, b"RIFF-test-wave")
download_id = await service.create_audio_id(voice_id)
assert await service.consume_audio(download_id) == b"RIFF-test-wave"
assert await service.consume_audio(download_id) is None
@pytest.mark.asyncio
async def test_voice_resources_by_user_keeps_java_created_at_query_failure() -> None:
async with sqlite_session(VOICE_SCHEMA) as session:
with pytest.raises(AppError) as caught:
await VoiceCloneService(session).get_by_user(9223372036854775806)
assert caught.value.code == 500
@pytest.mark.asyncio
async def test_huoshan_clone_request_and_success_state_are_persisted() -> None:
fake = cast(Redis, FakeRedis())
requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
payload = json.loads(request.content)
assert payload["appid"] == "app-id"
assert base64.b64decode(payload["audios"][0]["audio_bytes"]) == b"voice-bytes"
assert payload["audios"][0]["audio_format"] == "wav"
assert payload["source"] == 2
assert payload["language"] == 0
assert payload["model_type"] == 1
assert payload["speaker_id"] == "S_original"
return httpx.Response(200, json={"BaseResp": {"StatusCode": 0}, "speaker_id": "S_trained"})
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
provider = VoiceCloneIntegration(timeout_seconds=1, client=client, endpoint="https://mock.local/train")
async with sqlite_session(VOICE_SCHEMA) as session:
await _seed_clone(session)
service = VoiceCloneService(session, redis_client=fake, provider=provider)
await service.clone_audio("clone-1", accept_language="zh-CN")
row = (
await session.execute(
text("SELECT voice_id,train_status,train_error FROM ai_voice_clone WHERE id='clone-1'")
)
).mappings().one()
assert len(requests) == 1
assert requests[0].headers["Authorization"] == "Bearer;access-token"
assert requests[0].headers["Resource-Id"] == "seed-icl-1.0"
assert row == {"voice_id": "S_trained", "train_status": 2, "train_error": ""}
@pytest.mark.asyncio
async def test_huoshan_timeout_maps_to_training_error_without_retry() -> None:
fake = cast(Redis, FakeRedis())
attempts = 0
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
raise httpx.ReadTimeout("provider timeout", request=request)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
provider = VoiceCloneIntegration(timeout_seconds=0.01, client=client, endpoint="https://mock.local/train")
async with sqlite_session(VOICE_SCHEMA) as session:
await _seed_clone(session)
service = VoiceCloneService(session, redis_client=fake, provider=provider)
with pytest.raises(AppError) as raised:
await service.clone_audio("clone-1", accept_language="en-US")
row = (
await session.execute(
text("SELECT train_status,train_error FROM ai_voice_clone WHERE id='clone-1'")
)
).mappings().one()
assert attempts == 1
assert raised.value.code == 10154
assert row["train_status"] == 3
assert "provider timeout" in row["train_error"]
@pytest.mark.asyncio
async def test_voice_play_route_consumes_link_and_preserves_audio_headers(monkeypatch: pytest.MonkeyPatch) -> None:
fake = cast(Redis, FakeRedis())
monkeypatch.setattr("app.services.device.get_redis", lambda: fake)
await cast(Any, fake).set("voiceClone:audio:id:play-once", b'"clone-1"', ex=86_400)
async with sqlite_session(VOICE_SCHEMA) as session:
await session.execute(
text(
"INSERT INTO ai_voice_clone "
"(id,name,model_id,voice_id,user_id,voice,train_status,creator,create_date) "
"VALUES ('clone-1','voice','tts','S_voice',8,:voice,0,8,CURRENT_TIMESTAMP)"
),
{"voice": b"RIFF-audio"},
)
await session.commit()
app = FastAPI()
app.include_router(voiceclone_router)
async def override_db() -> Any:
yield session
app.dependency_overrides[get_db] = override_db
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
first = await client.get("/voiceClone/play/play-once")
second = await client.get("/voiceClone/play/play-once")
assert first.status_code == 200
assert first.content == b"RIFF-audio"
assert first.headers["content-type"] == "audio/wav"
assert first.headers["content-length"] == str(len(first.content))
assert first.headers["content-disposition"] == "inline; filename=voice.wav"
assert second.status_code == 404
async def _seed_clone(session: Any) -> None:
await session.execute(text("INSERT INTO sys_user VALUES (8, 'voice-user')"))
await session.execute(
text(
"INSERT INTO ai_model_config (id,model_type,model_name,config_json) "
"VALUES ('tts-1','TTS','火山双向流',:config)"
),
{
"config": json.dumps(
{"type": "huoshan_double_stream", "appid": "app-id", "access_token": "access-token"}
)
},
)
await session.execute(
text(
"INSERT INTO ai_voice_clone "
"(id,name,model_id,voice_id,languages,user_id,voice,train_status,creator,create_date) "
"VALUES ('clone-1','clone','tts-1','S_original','zh-CN',8,:voice,0,8,CURRENT_TIMESTAMP)"
),
{"voice": b"voice-bytes"},
)
await session.commit()