mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-22 23:23:55 +08:00
174 lines
5.4 KiB
Python
174 lines
5.4 KiB
Python
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
|