mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-22 23:23:55 +08:00
feat: add FastAPI manager API compatibility baseline
This commit is contained in:
@@ -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/154(100%)" 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 XML:20 个" 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()
|
||||
Reference in New Issue
Block a user