""" Tests du Fix P0-E : FileNotFoundError dans _send_image n'est pas un succès. Avant : un fichier image disparu retournait `True` (succès logique) — donc le buffer SQLite supprimait l'entrée alors que le serveur n'avait jamais reçu l'image. Perte silencieuse, contradiction avec la sémantique "succès = HTTP 200". Après : retourne `ImageSendResult.FILE_GONE` distinct de `OK`. Le drain du buffer supprime l'entrée mais avec un log ERROR explicite (pas de retry, pas de confusion avec un succès réseau). """ from __future__ import annotations import sys from pathlib import Path from unittest.mock import MagicMock, patch import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[2])) @pytest.fixture def isolated_buffer(tmp_path, monkeypatch): """Isole le buffer persistant dans un tmp_path par test.""" from agent_v0.agent_v1.network import streamer as streamer_mod buffer_dir = tmp_path / "buffer" monkeypatch.setattr(streamer_mod, "BUFFER_DIR", buffer_dir) return buffer_dir class TestImageSendResultEnum: """Vérifier l'existence et le contrat de l'enum ImageSendResult.""" def test_enum_has_three_values(self): from agent_v0.agent_v1.network.streamer import ImageSendResult assert ImageSendResult.OK.value == "ok" assert ImageSendResult.FAILED.value == "failed" assert ImageSendResult.FILE_GONE.value == "file_gone" def test_enum_values_distinct(self): from agent_v0.agent_v1.network.streamer import ImageSendResult assert ImageSendResult.OK is not ImageSendResult.FAILED assert ImageSendResult.OK is not ImageSendResult.FILE_GONE assert ImageSendResult.FAILED is not ImageSendResult.FILE_GONE class TestSendImageReturnsFileGone: """_send_image doit retourner FILE_GONE si le fichier n'existe pas.""" def test_missing_file_returns_file_gone(self, tmp_path, isolated_buffer): """Fichier inexistant → FILE_GONE (pas OK, pas FAILED).""" from agent_v0.agent_v1.network.streamer import ( ImageSendResult, TraceStreamer, ) streamer = TraceStreamer("sess_p0e_001") streamer._server_available = True # On NE crée pas le fichier missing_path = str(tmp_path / "i_do_not_exist.png") with patch("agent_v0.agent_v1.network.streamer.requests"): result = streamer._send_image(missing_path, "shot_lost") assert result is ImageSendResult.FILE_GONE, ( f"Attendu FILE_GONE, reçu {result}" ) def test_file_gone_is_not_truthy_for_legacy_callers( self, tmp_path, isolated_buffer ): """Un caller legacy qui fait `if result:` ne doit PAS interpréter FILE_GONE comme un succès.""" from agent_v0.agent_v1.network.streamer import ImageSendResult # FILE_GONE est un membre d'enum non vide → en Python il est truthy # par défaut. C'est pour ça qu'on ne peut PAS se contenter du test # bool(result) pour distinguer succès/échec : il faut comparer is OK. # Ce test documente le contrat : les callers DOIVENT comparer is OK. result = ImageSendResult.FILE_GONE assert result is not ImageSendResult.OK assert result is not True class TestDrainHandlesFileGone: """Le drain du buffer doit supprimer l'entrée FILE_GONE avec log ERROR.""" def test_drain_removes_buffer_entry_for_missing_file( self, tmp_path, isolated_buffer, caplog ): """Si le fichier disparait entre la persistance et le drain : - L'entrée est supprimée du buffer (pas de retry infini) - Un log ERROR signale la perte """ import logging from agent_v0.agent_v1.network.streamer import TraceStreamer streamer = TraceStreamer("sess_p0e_drain") streamer._server_available = False # Persister une image vers un chemin inexistant ghost_path = str(tmp_path / "ghost.png") streamer.push_image(ghost_path, "shot_ghost") buf = streamer._get_buffer() assert buf.counts()["images"] == 1 # Drain avec serveur dispo : doit détecter l'absence et abandonner streamer._server_available = True with caplog.at_level(logging.ERROR, logger="agent_v0.agent_v1.network.streamer"): with patch("agent_v0.agent_v1.network.streamer.requests"): streamer._drain_buffer_once(buf) assert buf.counts()["images"] == 0, ( "L'entrée doit être supprimée (retry voué à échouer)" ) # Vérifier qu'un log ERROR a été émis (pas seulement un warning) error_logs = [r for r in caplog.records if r.levelno >= logging.ERROR] assert len(error_logs) >= 1, ( "Un log ERROR doit signaler que le serveur n'a rien reçu" ) assert any( "abandonnée" in r.getMessage() or "introuvable" in r.getMessage() or "abandonnée" in r.getMessage().lower() for r in error_logs ) def test_send_image_file_disappears_during_send( self, tmp_path, isolated_buffer, caplog ): """Cas tordu : le fichier existe au moment de drain_images mais disparait pendant _send_image (race condition disque). On simule en patchant _compress_image_to_jpeg pour lever FileNotFoundError. """ import logging from agent_v0.agent_v1.network.streamer import ( ImageSendResult, TraceStreamer, ) # Fichier existant initialement img_path = tmp_path / "race.png" img_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50) streamer = TraceStreamer("sess_p0e_race") streamer._server_available = True # Forcer FileNotFoundError dans le pipeline d'envoi (compression # tente d'ouvrir le fichier — qui aura "disparu" entre temps). def _gone(_path): raise FileNotFoundError(f"race condition: {_path}") with patch.object(streamer, "_compress_image_to_jpeg", _gone), \ patch("agent_v0.agent_v1.network.streamer.requests"), \ caplog.at_level(logging.ERROR, logger="agent_v0.agent_v1.network.streamer"): result = streamer._send_image(str(img_path), "shot_race") assert result is ImageSendResult.FILE_GONE, ( "FileNotFoundError pendant la compression → FILE_GONE" ) # Log ERROR (pas debug comme avant) error_logs = [r for r in caplog.records if r.levelno >= logging.ERROR] assert len(error_logs) >= 1 class TestStreamLoopHandlesFileGone: """La boucle d'envoi ne doit PAS persister une entrée FILE_GONE.""" def test_file_gone_not_persisted_to_buffer( self, tmp_path, isolated_buffer ): """Quand _send_image retourne FILE_GONE, on ne réécrit pas dans le buffer (sinon boucle infinie : add → drain → file_gone → add…).""" from agent_v0.agent_v1.network.streamer import ( ImageSendResult, TraceStreamer, ) streamer = TraceStreamer("sess_p0e_loop") streamer._server_available = True # Mock _send_with_retry pour retourner FILE_GONE directement with patch.object( streamer, "_send_with_retry", return_value=ImageSendResult.FILE_GONE ): # Mettre une image dans la queue streamer.queue.put(("image", ("/tmp/whatever.png", "shot_x"))) # Lancer une seule itération de la boucle (en simulant) try: item_type, data = streamer.queue.get(timeout=0.1) # Reproduire la logique du _stream_loop result = streamer._send_with_retry( streamer._send_image, *data ) assert result is ImageSendResult.FILE_GONE # Le caller (stream_loop) doit identifier FILE_GONE comme # "ne pas persister" → on vérifie que le buffer reste vide buf = streamer._get_buffer() # Avant le fix : l'item aurait été persisté car "consecutive_failures += 1" # et "if priority_item: persist()". Avec le fix, on saute. assert buf.counts()["images"] == 0 finally: streamer.queue.task_done()