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,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
|
||||
Reference in New Issue
Block a user