109 lines
3.8 KiB
Python
109 lines
3.8 KiB
Python
"""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
|