"""FC-2e: verify_integrity — sha + decode/probe per ImageRecord.""" import subprocess import pytest from PIL import Image from backend.app.models import ImageRecord from backend.app.tasks.maintenance import verify_integrity pytestmark = pytest.mark.integration class _Ctx: def __init__(self, s): self.s = s def __enter__(self): return self.s def __exit__(self, *a): return False def _sf(db_sync): class _SM: def __call__(self): return _Ctx(db_sync) return _SM() def _good_jpeg(path): path.parent.mkdir(parents=True, exist_ok=True) Image.new("RGB", (32, 32), (40, 80, 160)).save(path, "JPEG") def _rec(db_sync, path, *, sha, mime="image/jpeg"): rec = ImageRecord( path=str(path), sha256=sha, phash=None, size_bytes=1, mime=mime, width=32, height=32, origin="imported_filesystem", integrity_status="unknown", ) db_sync.add(rec) db_sync.flush() return rec def _sha_of(path): import hashlib h = hashlib.sha256() h.update(path.read_bytes()) return h.hexdigest() def test_ok_corrupt_failed_paths(db_sync, tmp_path, monkeypatch): from backend.app.tasks import maintenance as m good = tmp_path / "good.jpg" _good_jpeg(good) good2 = tmp_path / "good2.jpg" # distinct path — image_record.path is unique _good_jpeg(good2) bad = tmp_path / "bad.jpg" bad.write_bytes(b"not a real jpeg") truncated = tmp_path / "trunc.jpg" _good_jpeg(truncated) truncated.write_bytes(truncated.read_bytes()[:30]) # break the bitstream rec_ok = _rec(db_sync, good, sha=_sha_of(good)) rec_sha_mismatch = _rec(db_sync, good2, sha="0" * 64) # bytes intact, sha wrong → corrupt rec_decode_fail = _rec(db_sync, truncated, sha=_sha_of(truncated)) # 'bad' has not-jpeg bytes; sha matches the bad bytes → decode fails rec_bad_decode = _rec(db_sync, bad, sha=_sha_of(bad)) rec_missing = _rec( db_sync, tmp_path / "gone.jpg", sha="g" + "0" * 63, ) db_sync.commit() monkeypatch.setattr(m, "_sync_session_factory", lambda: _sf(db_sync)) total = verify_integrity() db_sync.expire_all() assert total == 5 assert db_sync.get(ImageRecord, rec_ok.id).integrity_status == "ok" assert db_sync.get(ImageRecord, rec_sha_mismatch.id).integrity_status == "corrupt" assert db_sync.get(ImageRecord, rec_decode_fail.id).integrity_status == "corrupt" assert db_sync.get(ImageRecord, rec_bad_decode.id).integrity_status == "corrupt" assert db_sync.get(ImageRecord, rec_missing.id).integrity_status == "failed_verification" # Idempotent — second run re-derives the same verdict. verify_integrity() db_sync.expire_all() assert db_sync.get(ImageRecord, rec_ok.id).integrity_status == "ok" def test_video_ffprobe_paths(db_sync, tmp_path, monkeypatch): from backend.app.tasks import maintenance as m vid = tmp_path / "v.mp4" vid.write_bytes(b"fake but consistent video bytes") rec = _rec(db_sync, vid, sha=_sha_of(vid), mime="video/mp4") db_sync.commit() monkeypatch.setattr(m, "_sync_session_factory", lambda: _sf(db_sync)) # Case A: ffprobe succeeds → ok def _run_ok(args, **kw): return subprocess.CompletedProcess(args, 0, b"", b"") monkeypatch.setattr(m.subprocess, "run", _run_ok) verify_integrity() db_sync.expire_all() assert db_sync.get(ImageRecord, rec.id).integrity_status == "ok" # Case B: ffprobe non-zero → corrupt def _run_bad(args, **kw): return subprocess.CompletedProcess(args, 1, b"", b"err") monkeypatch.setattr(m.subprocess, "run", _run_bad) verify_integrity() db_sync.expire_all() assert db_sync.get(ImageRecord, rec.id).integrity_status == "corrupt" # Case C: ffprobe times out → corrupt def _run_timeout(args, **kw): raise subprocess.TimeoutExpired(cmd=args, timeout=10) monkeypatch.setattr(m.subprocess, "run", _run_timeout) verify_integrity() db_sync.expire_all() assert db_sync.get(ImageRecord, rec.id).integrity_status == "corrupt" # Case D: ffprobe binary missing → failed_verification def _run_missing(args, **kw): raise FileNotFoundError("ffprobe") monkeypatch.setattr(m.subprocess, "run", _run_missing) verify_integrity() db_sync.expire_all() assert db_sync.get(ImageRecord, rec.id).integrity_status == "failed_verification"