"""Tests du bus feedback Léa (events lea:* via Flask-SocketIO). Couvre J2.5 et J2.6 : - Flag LEA_FEEDBACK_BUS=0 → _emit_lea no-op, _emit_dual ne propage que l'event legacy - Flag LEA_FEEDBACK_BUS=1 → _emit_lea propage 'lea:{event}', _emit_dual propage les deux Approche : on intercepte socketio.emit avec monkeypatch (plus fiable que test_client de Flask-SocketIO qui ne capte pas toujours les broadcasts hors contexte requête). """ import importlib import pytest def _reload_app(monkeypatch, flag_value: str): monkeypatch.setenv("LEA_FEEDBACK_BUS", flag_value) import agent_chat.app as app_mod importlib.reload(app_mod) return app_mod def _capture_emits(monkeypatch, app_mod): calls = [] monkeypatch.setattr( app_mod.socketio, "emit", lambda event, payload=None, **kwargs: calls.append((event, payload, kwargs)), ) return calls @pytest.fixture def app_off(monkeypatch): return _reload_app(monkeypatch, "0") @pytest.fixture def app_on(monkeypatch): return _reload_app(monkeypatch, "1") def test_flag_off_by_default(monkeypatch): monkeypatch.delenv("LEA_FEEDBACK_BUS", raising=False) import agent_chat.app as app_mod importlib.reload(app_mod) assert app_mod.LEA_FEEDBACK_BUS is False def test_flag_accepts_truthy_values(monkeypatch): for truthy in ["1", "true", "True", "yes", "on", "TRUE"]: monkeypatch.setenv("LEA_FEEDBACK_BUS", truthy) import agent_chat.app as app_mod importlib.reload(app_mod) assert app_mod.LEA_FEEDBACK_BUS is True, f"{truthy!r} devrait activer le flag" def test_emit_lea_noop_when_flag_off(app_off, monkeypatch): calls = _capture_emits(monkeypatch, app_off) app_off._emit_lea("paused", {"workflow": "demo", "reason": "test"}) assert calls == [] def test_emit_lea_emits_when_flag_on(app_on, monkeypatch): calls = _capture_emits(monkeypatch, app_on) app_on._emit_lea("paused", {"workflow": "demo", "reason": "test"}) assert len(calls) == 1 event, payload, _ = calls[0] assert event == "lea:paused" assert payload == {"workflow": "demo", "reason": "test"} def test_emit_dual_emits_only_legacy_when_flag_off(app_off, monkeypatch): calls = _capture_emits(monkeypatch, app_off) app_off._emit_dual("execution_started", "action_started", {"workflow": "demo"}) assert len(calls) == 1 assert calls[0][0] == "execution_started" def test_emit_dual_emits_both_when_flag_on(app_on, monkeypatch): calls = _capture_emits(monkeypatch, app_on) payload = {"workflow": "demo", "params": {"k": "v"}} app_on._emit_dual("execution_started", "action_started", payload) events = [c[0] for c in calls] assert "execution_started" in events assert "lea:action_started" in events assert len(calls) == 2 def test_emit_dual_preserves_kwargs(app_on, monkeypatch): """broadcast=True et autres kwargs Flask-SocketIO doivent être propagés au legacy.""" calls = _capture_emits(monkeypatch, app_on) app_on._emit_dual("execution_cancelled", "cancelled", {}, broadcast=True) legacy_call = next(c for c in calls if c[0] == "execution_cancelled") assert legacy_call[2].get("broadcast") is True def test_emit_lea_silenced_on_socketio_error(app_on, monkeypatch): """Une exception dans socketio.emit ne doit jamais remonter.""" def boom(*args, **kwargs): raise RuntimeError("socketio fail") monkeypatch.setattr(app_on.socketio, "emit", boom) app_on._emit_lea("paused", {"x": 1}) # ---------------------------------------------------------------------- # J3.5 — Handlers SocketIO depuis ChatWindow # ---------------------------------------------------------------------- class _FakeResponse: def __init__(self, ok=True, status_code=200, text=""): self.ok = ok self.status_code = status_code self.text = text def test_replay_resume_handler_relays_post_to_streaming(app_on, monkeypatch): """Le handler 'lea:replay_resume' doit POSTer sur /replay/{id}/resume du streaming.""" captured = {} def fake_post(url, headers=None, **kwargs): captured["url"] = url captured["headers"] = headers return _FakeResponse(ok=True, status_code=200) monkeypatch.setattr(app_on.http_requests, "post", fake_post) emit_calls = _capture_emits(monkeypatch, app_on) app_on.handle_lea_replay_resume({"replay_id": "rep_abc123"}) assert "rep_abc123" in captured["url"] assert captured["url"].endswith("/api/v1/traces/stream/replay/rep_abc123/resume") # Le bus doit propager un ack acked = [c for c in emit_calls if c[0] == "lea:resume_acked"] assert len(acked) == 1 assert acked[0][1]["status"] == "ok" def test_replay_resume_handler_emits_error_on_http_failure(app_on, monkeypatch): monkeypatch.setattr( app_on.http_requests, "post", lambda *a, **k: _FakeResponse(ok=False, status_code=500, text="boom"), ) emit_calls = _capture_emits(monkeypatch, app_on) app_on.handle_lea_replay_resume({"replay_id": "rep_x"}) acked = [c for c in emit_calls if c[0] == "lea:resume_acked"] assert acked[0][1]["status"] == "error" assert acked[0][1]["http_status"] == 500 def test_replay_resume_handler_emits_error_on_no_replay_id(app_on, monkeypatch): emit_calls = _capture_emits(monkeypatch, app_on) app_on.handle_lea_replay_resume({}) acked = [c for c in emit_calls if c[0] == "lea:resume_acked"] assert acked[0][1]["status"] == "error" assert "replay_id manquant" in acked[0][1]["detail"] def test_replay_abort_handler_stops_local_execution(app_on, monkeypatch): app_on.execution_status["running"] = True emit_calls = _capture_emits(monkeypatch, app_on) app_on.handle_lea_replay_abort({"replay_id": "rep_y"}) assert app_on.execution_status["running"] is False acked = [c for c in emit_calls if c[0] == "lea:abort_acked"] assert acked[0][1]["status"] == "ok"