- Nouveau module persistent_buffer.py (SQLite WAL, thread-safe)
- Purge automatique des captures locales après ACK 200 serveur
- Drain loop 15s, retry exponentiel, plafonds tentatives
- Enum ImageSendResult.{OK, FAILED, FILE_GONE} pour distinguer les cas
- FileNotFoundError n'est plus un faux succès (P0-E audit)
- 14 tests intégration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
215 lines
8.2 KiB
Python
215 lines
8.2 KiB
Python
"""
|
|
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()
|