""" Tests de compatibilité Client (Agent V1) ↔ Serveur (api_stream). Vérifie que les payloads envoyés par le TraceStreamer correspondent exactement à ce que l'API serveur attend (formats, champs, endpoints). Sans réseau réel : on mocke requests.post et on valide les appels. """ import sys from pathlib import Path from unittest.mock import MagicMock, call, patch import pytest _ROOT = str(Path(__file__).resolve().parents[2]) if _ROOT not in sys.path: sys.path.insert(0, _ROOT) # ========================================================================= # TraceStreamer ↔ API endpoints # ========================================================================= class TestStreamerEndpoints: """Vérifie que le client appelle les bons endpoints.""" def test_register_endpoint(self): """start() appelle POST /register avec session_id.""" from agent_v0.agent_v1.network.streamer import TraceStreamer with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req: mock_req.post.return_value = MagicMock(ok=True) streamer = TraceStreamer("sess_test_001") streamer.start() streamer.stop() # Trouver l'appel register register_calls = [ c for c in mock_req.post.call_args_list if "/register" in str(c) ] assert len(register_calls) >= 1, "register endpoint jamais appelé" _, kwargs = register_calls[0] assert kwargs["params"]["session_id"] == "sess_test_001" def test_finalize_endpoint(self): """stop() appelle POST /finalize avec session_id.""" from agent_v0.agent_v1.network.streamer import TraceStreamer with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req: mock_req.post.return_value = MagicMock(ok=True, json=lambda: {"status": "ok"}) streamer = TraceStreamer("sess_test_002") streamer._server_available = True streamer.running = False streamer._finalize_session() finalize_calls = [ c for c in mock_req.post.call_args_list if "/finalize" in str(c) ] assert len(finalize_calls) >= 1, "finalize endpoint jamais appelé" _, kwargs = finalize_calls[0] assert kwargs["params"]["session_id"] == "sess_test_002" # ========================================================================= # Payload formats # ========================================================================= class TestEventPayloadFormat: """Vérifie que les événements envoyés ont le bon format.""" def test_event_payload_matches_server_model(self): """Le payload event doit contenir session_id, timestamp, event.""" from agent_v0.agent_v1.network.streamer import TraceStreamer captured_payload = {} with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req: mock_req.post.return_value = MagicMock(ok=True) streamer = TraceStreamer("sess_test_003") streamer._server_available = True # Envoyer directement (sans thread) test_event = { "type": "mouse_click", "button": "left", "pos": (500, 300), "timestamp": 1234567890.0, "window": {"title": "Firefox", "app_name": "firefox"}, } streamer._send_event(test_event) # Vérifier le payload envoyé event_calls = [ c for c in mock_req.post.call_args_list if "/event" in str(c) ] assert len(event_calls) == 1 _, kwargs = event_calls[0] payload = kwargs["json"] # Champs requis par le modèle Pydantic StreamEvent du serveur assert "session_id" in payload assert "timestamp" in payload assert "event" in payload assert payload["session_id"] == "sess_test_003" assert isinstance(payload["timestamp"], float) assert payload["event"]["type"] == "mouse_click" def test_event_with_window_info(self): """Le serveur utilise event.window pour last_window_info.""" from agent_v0.agent_v1.network.streamer import TraceStreamer with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req: mock_req.post.return_value = MagicMock(ok=True) streamer = TraceStreamer("sess_test_004") streamer._server_available = True event_with_window = { "type": "mouse_click", "window": {"title": "Chrome", "app_name": "chrome"}, } streamer._send_event(event_with_window) event_calls = [ c for c in mock_req.post.call_args_list if "/event" in str(c) ] payload = event_calls[0][1]["json"] # Le champ window doit être transmis au serveur assert "window" in payload["event"] assert payload["event"]["window"]["title"] == "Chrome" assert payload["event"]["window"]["app_name"] == "chrome" class TestImagePayloadFormat: """Vérifie le format d'envoi des screenshots.""" def test_image_params_match_server(self, tmp_path): """L'envoi image utilise les bons params query (session_id, shot_id).""" from agent_v0.agent_v1.network.streamer import TraceStreamer # Créer un faux fichier image fake_img = tmp_path / "test.png" fake_img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100) with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req: mock_req.post.return_value = MagicMock(ok=True) streamer = TraceStreamer("sess_test_005") streamer._server_available = True streamer._send_image(str(fake_img), "shot_0001_full") img_calls = [ c for c in mock_req.post.call_args_list if "/image" in str(c) ] assert len(img_calls) == 1 _, kwargs = img_calls[0] # Vérifier les params query assert kwargs["params"]["session_id"] == "sess_test_005" assert kwargs["params"]["shot_id"] == "shot_0001_full" # Vérifier que le fichier est envoyé assert "files" in kwargs assert "file" in kwargs["files"] def test_empty_path_ignored(self): """push_image avec chemin vide ne doit pas enqueue.""" from agent_v0.agent_v1.network.streamer import TraceStreamer streamer = TraceStreamer("sess_test_006") streamer.push_image("", "heartbeat_empty") assert streamer.queue.empty(), "Chemin vide ne doit pas être enfilé" def test_crop_naming_convention(self, tmp_path): """Le serveur distingue full/crop par '_crop' dans le shot_id.""" from agent_v0.agent_v1.network.streamer import TraceStreamer # Dans le monde réel, full et crop sont deux fichiers distincts # (la purge après ACK supprime le premier avant que le second parte). fake_full = tmp_path / "full.png" fake_full.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50) fake_crop = tmp_path / "crop.png" fake_crop.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50) with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req: mock_req.post.return_value = MagicMock(ok=True) streamer = TraceStreamer("sess_test_007") streamer._server_available = True # Full screenshot streamer._send_image(str(fake_full), "shot_0001_full") # Crop screenshot streamer._send_image(str(fake_crop), "shot_0001_crop") img_calls = [ c for c in mock_req.post.call_args_list if "/image" in str(c) ] assert len(img_calls) == 2 shot_ids = [c[1]["params"]["shot_id"] for c in img_calls] assert "shot_0001_full" in shot_ids assert "shot_0001_crop" in shot_ids # Vérifier que le serveur pourra distinguer # (api_stream.py check "_crop" in shot_id) assert "_crop" in "shot_0001_crop" assert "_crop" not in "shot_0001_full" # ========================================================================= # Server-side validation (StreamEvent model) # ========================================================================= class TestServerModelValidation: """Vérifie que les payloads client passent la validation Pydantic côté serveur.""" def test_streamevent_model_accepts_client_payload(self): """Le payload client est accepté par le modèle StreamEvent du serveur.""" import agent_v0.server_v1 # noqa: F401 — force le bon import from agent_v0.server_v1.api_stream import StreamEvent # Payload typique envoyé par le client payload = { "session_id": "sess_20260311T100530_abc123", "timestamp": 1741689930.123, "event": { "type": "mouse_click", "button": "left", "pos": [500, 300], "timestamp": 1741689930.123, "window": {"title": "Firefox", "app_name": "firefox"}, "screenshot_id": "shot_0001", }, } model = StreamEvent(**payload) assert model.session_id == "sess_20260311T100530_abc123" assert model.event["type"] == "mouse_click" assert model.event["window"]["title"] == "Firefox" def test_streamevent_heartbeat(self): """Heartbeat events passent la validation.""" import agent_v0.server_v1 # noqa: F401 from agent_v0.server_v1.api_stream import StreamEvent payload = { "session_id": "sess_heartbeat", "timestamp": 1741689935.0, "event": { "type": "heartbeat", "image": "/tmp/shots/context_1741689935_heartbeat.png", "timestamp": 1741689935.0, }, } model = StreamEvent(**payload) assert model.event["type"] == "heartbeat" def test_streamevent_window_focus_change(self): """Window focus change events passent la validation.""" import agent_v0.server_v1 # noqa: F401 from agent_v0.server_v1.api_stream import StreamEvent payload = { "session_id": "sess_focus", "timestamp": 1741689940.0, "event": { "type": "window_focus_change", "from": {"title": "Terminal", "app_name": "gnome-terminal"}, "to": {"title": "Firefox", "app_name": "firefox"}, "timestamp": 1741689940.0, }, } model = StreamEvent(**payload) assert model.event["type"] == "window_focus_change" # ========================================================================= # Server processes client data correctly # ========================================================================= class TestServerProcessesClientData: """Vérifie que le serveur traite correctement les données du client.""" def test_window_info_extracted_from_event(self): """Le LiveSessionManager extrait window info des événements.""" import agent_v0.server_v1 # noqa: F401 from agent_v0.server_v1.live_session_manager import LiveSessionManager mgr = LiveSessionManager() # Événement typique envoyé par l'Agent V1 mgr.add_event("sess_client", { "type": "mouse_click", "button": "left", "pos": [500, 300], "window": {"title": "Firefox", "app_name": "firefox"}, }) session = mgr.get_session("sess_client") assert session.last_window_info["title"] == "Firefox" assert session.last_window_info["app_name"] == "firefox" def test_crop_filtered_in_raw_session(self): """Les crops sont filtrés lors de la conversion RawSession.""" import agent_v0.server_v1 # noqa: F401 from agent_v0.server_v1.live_session_manager import LiveSessionManager mgr = LiveSessionManager() # Le client envoie full + crop mgr.add_screenshot("sess_raw", "shot_0001_full", "/tmp/full.png") mgr.add_screenshot("sess_raw", "shot_0001_crop", "/tmp/crop.png") raw = mgr.to_raw_session("sess_raw") # Seul le full doit apparaître dans RawSession assert len(raw["screenshots"]) == 1 assert raw["screenshots"][0]["screenshot_id"] == "shot_0001_full" def test_server_failure_tracking(self): """Le streamer désactive les envois après 10 échecs consécutifs.""" from agent_v0.agent_v1.network.streamer import TraceStreamer with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req: mock_req.post.return_value = MagicMock(ok=False, status_code=500) streamer = TraceStreamer("sess_fail") streamer._server_available = True # 10 échecs consécutifs for _ in range(10): streamer._send_event({"type": "test"}) # Le streamer est toujours _server_available=True car # c'est la boucle _stream_loop qui fait le tracking. # Mais _send_event retourne False assert not streamer._send_event({"type": "test"})