feat: add FastAPI manager API compatibility baseline

This commit is contained in:
Tyke Chen
2026-07-20 17:00:13 +08:00
parent 7c58fa37b2
commit 804ddb51f2
140 changed files with 47169 additions and 1 deletions
@@ -0,0 +1,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