"""Tests fuer app/recordings/recorder.py (Umsetzungsauftrag Teil A D2 / Teil E E2): Puffer/Executor-Auslagerung, Hash-Kette, Rotation und Groessenbegrenzung.""" import asyncio import json import pytest from app.recordings import recorder as rec_mod @pytest.fixture(autouse=True) def _isolated_recordings_dir(tmp_path, monkeypatch): """settings ist ein Prozess-Singleton (siehe conftest.py) -- fuer Testisolation wird recordings_dir direkt umgebogen statt ueber die Umgebungsvariable (die nach dem ersten Import wirkungslos waere).""" from app.config import settings recordings_dir = tmp_path / "recordings" recordings_dir.mkdir(parents=True, exist_ok=True) monkeypatch.setattr(settings, "recordings_dir", recordings_dir) monkeypatch.setattr(rec_mod.SessionRecorder, "FLUSH_INTERVAL_S", 0.02) yield async def test_record_is_non_blocking_and_flushes_via_background_task(): rec = rec_mod.SessionRecorder(session_id=1) for i in range(10): rec.record("output", f"frame-{i}") with open(rec.path, encoding="utf-8") as fh: assert fh.read() == "" # record() darf nicht sofort schreiben await asyncio.sleep(0.2) with open(rec.path, encoding="utf-8") as fh: lines = [l for l in fh if l.strip()] assert len(lines) == 10 assert rec_mod.verify_recording(rec.path) await rec.aclose() async def test_aclose_flushes_pending_buffer_even_with_long_interval(): rec_mod.SessionRecorder.FLUSH_INTERVAL_S = 100 rec = rec_mod.SessionRecorder(session_id=2) for i in range(5): rec.record("input", f"x{i}") await rec.aclose() with open(rec.path, encoding="utf-8") as fh: lines = [l for l in fh if l.strip()] assert len(lines) == 5 assert rec_mod.verify_recording(rec.path) async def test_concurrent_recorders_do_not_interfere(): recs = [rec_mod.SessionRecorder(session_id=100 + i) for i in range(5)] for i, r in enumerate(recs): for j in range(20): r.record("output", f"s{i}-{j}") await asyncio.sleep(0.3) for r in recs: with open(r.path, encoding="utf-8") as fh: lines = [l for l in fh if l.strip()] assert len(lines) == 20 assert rec_mod.verify_recording(r.path) await r.aclose() async def test_rotation_splits_into_multiple_valid_parts(): from app.config import settings settings.recording_max_part_bytes = 400 settings.recording_max_total_bytes = 10_000_000 rec = rec_mod.SessionRecorder(session_id=3) rec._max_part_bytes = 400 rec._max_total_bytes = 10_000_000 for i in range(60): rec.record("output", f"payload-{i:04d}-" + ("x" * 20)) await rec.aclose() parts = list(rec_mod._iter_part_paths(rec.path)) assert len(parts) > 1 assert rec_mod.verify_recording_set(rec.path) assert rec_mod.count_recording_entries(rec.path) == 60 for p in parts: with open(p, encoding="utf-8") as fh: first_line = next((l for l in fh if l.strip()), None) assert first_line is not None assert json.loads(first_line)["prev_hash"] == rec_mod.GENESIS_HASH async def test_total_size_limit_truncates_and_stops_accepting_entries(): rec = rec_mod.SessionRecorder(session_id=4) rec._max_part_bytes = 10_000_000 rec._max_total_bytes = 500 for i in range(50): rec.record("output", f"payload-{i:04d}-" + ("y" * 20)) await rec.aclose() total_entries = rec_mod.count_recording_entries(rec.path) assert 0 < total_entries < 50 assert rec_mod.verify_recording_set(rec.path) entries = list(rec_mod.iter_recording_entries(rec.path)) assert "truncated" in entries[-1]["data"] rec.record("output", "should-be-dropped") await asyncio.sleep(0.1) assert rec_mod.count_recording_entries(rec.path) == total_entries