feat: remote client deel 1
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||
|
||||
from backend.app.dependencies import get_remote_client_service
|
||||
from backend.app.db.remote_client_repository import RemoteClientRepository
|
||||
from backend.app.main import app
|
||||
from backend.app.services.remote_client_service import RemoteClientService
|
||||
|
||||
|
||||
class _Clock:
|
||||
def __init__(self, current: datetime):
|
||||
self.current = current
|
||||
|
||||
def now(self) -> datetime:
|
||||
return self.current
|
||||
|
||||
def advance(self, *, seconds: int) -> None:
|
||||
self.current += timedelta(seconds=seconds)
|
||||
|
||||
|
||||
class RemoteClientsApiGoldenTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.clock = _Clock(datetime(2026, 3, 26, 12, 0, 0, tzinfo=timezone.utc))
|
||||
repository = RemoteClientRepository(str(Path(self.temp_dir.name) / "remote-clients.db"))
|
||||
service = RemoteClientService(
|
||||
repository=repository,
|
||||
registration_token="secret-token",
|
||||
offline_timeout_seconds=60,
|
||||
now=self.clock.now,
|
||||
)
|
||||
|
||||
async def _override_remote_client_service() -> RemoteClientService:
|
||||
return service
|
||||
|
||||
app.dependency_overrides[get_remote_client_service] = _override_remote_client_service
|
||||
|
||||
def tearDown(self) -> None:
|
||||
app.dependency_overrides.clear()
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def _request(self, method: str, url: str, payload: dict | None = None, token: str | None = None) -> httpx.Response:
|
||||
async def _run() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
headers = {}
|
||||
if token is not None:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
if method == "GET":
|
||||
return await client.get(url, headers=headers)
|
||||
return await client.post(url, json=payload, headers=headers)
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
@staticmethod
|
||||
def _register_payload() -> dict:
|
||||
return {
|
||||
"client_id": "client-123",
|
||||
"display_name": "Jan MacBook",
|
||||
"platform": "macos",
|
||||
"agent_version": "1.1.0",
|
||||
"endpoint": "http://192.168.1.25:8765",
|
||||
"shares": [{"key": "downloads", "label": "Downloads"}],
|
||||
}
|
||||
|
||||
def test_list_is_empty_by_default(self) -> None:
|
||||
response = self._request("GET", "/api/clients")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"items": []})
|
||||
|
||||
def test_register_then_list_then_heartbeat_and_status_timeout(self) -> None:
|
||||
register_response = self._request(
|
||||
"POST",
|
||||
"/api/clients/register",
|
||||
self._register_payload(),
|
||||
token="secret-token",
|
||||
)
|
||||
|
||||
self.assertEqual(register_response.status_code, 200)
|
||||
register_body = register_response.json()
|
||||
self.assertEqual(register_body["client_id"], "client-123")
|
||||
self.assertEqual(register_body["display_name"], "Jan MacBook")
|
||||
self.assertEqual(register_body["status"], "online")
|
||||
self.assertEqual(register_body["last_seen"], "2026-03-26T12:00:00Z")
|
||||
self.assertIsNone(register_body["last_error"])
|
||||
self.assertIsNone(register_body["reachable_at"])
|
||||
|
||||
list_response = self._request("GET", "/api/clients")
|
||||
self.assertEqual(list_response.status_code, 200)
|
||||
self.assertEqual(len(list_response.json()["items"]), 1)
|
||||
self.assertEqual(list_response.json()["items"][0]["status"], "online")
|
||||
|
||||
self.clock.advance(seconds=30)
|
||||
heartbeat_response = self._request(
|
||||
"POST",
|
||||
"/api/clients/heartbeat",
|
||||
{"client_id": "client-123", "agent_version": "1.1.1"},
|
||||
token="secret-token",
|
||||
)
|
||||
self.assertEqual(heartbeat_response.status_code, 200)
|
||||
heartbeat_body = heartbeat_response.json()
|
||||
self.assertEqual(heartbeat_body["agent_version"], "1.1.1")
|
||||
self.assertEqual(heartbeat_body["last_seen"], "2026-03-26T12:00:30Z")
|
||||
self.assertEqual(heartbeat_body["status"], "online")
|
||||
|
||||
self.clock.advance(seconds=61)
|
||||
timed_out_list = self._request("GET", "/api/clients")
|
||||
self.assertEqual(timed_out_list.status_code, 200)
|
||||
timed_out_item = timed_out_list.json()["items"][0]
|
||||
self.assertEqual(timed_out_item["status"], "offline")
|
||||
self.assertEqual(timed_out_item["last_seen"], "2026-03-26T12:00:30Z")
|
||||
self.assertIsNone(timed_out_item["last_error"])
|
||||
self.assertIsNone(timed_out_item["reachable_at"])
|
||||
|
||||
def test_register_rejects_invalid_token(self) -> None:
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/api/clients/register",
|
||||
self._register_payload(),
|
||||
token="wrong-token",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"]["code"], "forbidden")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user