Ctrl K
ComunidadGuías…

backend/tests/test_dedup_engine.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Tests for dedup_engine.py — SHA-256 hashing, duplicate scanning,
orphan detection, safe deletion, and disk space analysis.

Covers:
- compute_subtitle_hash(): file hashing with normalization, language extraction, errors
- compute_content_hash_from_bytes(): in-memory hashing (existing tests in test_dedup_detection.py)
- scan_for_duplicates(): directory walk, parallel hashing, DB upsert, progress emission
- delete_duplicates(): safety guards, path validation, symlink skip, DB cleanup
- scan_orphaned_subtitles(): media-matching logic, language tag stripping
- get_disk_space_analysis(): delegation to CleanupRepository
"""

import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, call, patch

import pytest

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from dedup_engine import (
    MEDIA_EXTENSIONS,
    SUBTITLE_EXTENSIONS,
    compute_content_hash_from_bytes,
    compute_subtitle_hash,
    delete_duplicates,
    get_disk_space_analysis,
    scan_for_duplicates,
    scan_orphaned_subtitles,
)

# ── Helpers ──────────────────────────────────────────────────────────────────


def _write_file(path: Path, content: str = "test content") -> Path:
    """Write a text file and return the Path object."""
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding="utf-8")
    return path


# ── TestComputeSubtitleHash ─────────────────────────────────────────────────


class TestComputeSubtitleHash:
    """Tests for compute_subtitle_hash() — file-based hashing."""

    def test_returns_expected_keys(self, tmp_path):
        f = _write_file(tmp_path / "test.srt", "1\n00:00:01,000 --> 00:00:02,000\nHello\n")
        result = compute_subtitle_hash(str(f))
        assert set(result.keys()) == {
            "file_path",
            "content_hash",
            "file_size",
            "format",
            "language",
            "line_count",
        }

    def test_hash_is_sha256_hex(self, tmp_path):
        f = _write_file(tmp_path / "test.srt", "content")
        result = compute_subtitle_hash(str(f))
        assert len(result["content_hash"]) == 64
        assert all(c in "0123456789abcdef" for c in result["content_hash"])

    def test_deterministic(self, tmp_path):
        f = _write_file(tmp_path / "test.srt", "same content")
        r1 = compute_subtitle_hash(str(f))
        r2 = compute_subtitle_hash(str(f))
        assert r1["content_hash"] == r2["content_hash"]

    def test_crlf_and_lf_produce_same_hash(self, tmp_path):
        f_lf = tmp_path / "lf.srt"
        f_lf.write_bytes(b"line1\nline2\n")
        f_crlf = tmp_path / "crlf.srt"
        f_crlf.write_bytes(b"line1\r\nline2\r\n")
        assert (
            compute_subtitle_hash(str(f_lf))["content_hash"]
            == compute_subtitle_hash(str(f_crlf))["content_hash"]
        )

    def test_whitespace_stripped(self, tmp_path):
        f1 = _write_file(tmp_path / "a.srt", "content")
        f2 = _write_file(tmp_path / "b.srt", "  content  \n\n")
        assert (
            compute_subtitle_hash(str(f1))["content_hash"]
            == compute_subtitle_hash(str(f2))["content_hash"]
        )

    def test_different_content_different_hash(self, tmp_path):
        f1 = _write_file(tmp_path / "a.srt", "alpha")
        f2 = _write_file(tmp_path / "b.srt", "beta")
        assert (
            compute_subtitle_hash(str(f1))["content_hash"]
            != compute_subtitle_hash(str(f2))["content_hash"]
        )

    def test_file_size_matches(self, tmp_path):
        content = "hello world"
        f = _write_file(tmp_path / "test.srt", content)
        result = compute_subtitle_hash(str(f))
        assert result["file_size"] == os.path.getsize(str(f))

    def test_format_extracted_from_extension(self, tmp_path):
        for ext in (".srt", ".ass", ".ssa"):
            f = _write_file(tmp_path / f"test{ext}", "content")
            result = compute_subtitle_hash(str(f))
            assert result["format"] == ext.lstrip(".")

    def test_language_extracted_from_filename(self, tmp_path):
        f = _write_file(tmp_path / "episode.en.srt", "content")
        result = compute_subtitle_hash(str(f))
        assert result["language"] == "en"

    def test_language_three_letter_code(self, tmp_path):
        f = _write_file(tmp_path / "episode.jpn.ass", "content")
        result = compute_subtitle_hash(str(f))
        assert result["language"] == "jpn"

    def test_language_none_when_no_tag(self, tmp_path):
        f = _write_file(tmp_path / "episode.srt", "content")
        result = compute_subtitle_hash(str(f))
        assert result["language"] is None

    def test_line_count(self, tmp_path):
        f = _write_file(tmp_path / "test.srt", "line1\nline2\nline3")
        result = compute_subtitle_hash(str(f))
        assert result["line_count"] == 3

    def test_file_not_found_raises(self, tmp_path):
        with pytest.raises(FileNotFoundError):
            compute_subtitle_hash(str(tmp_path / "nonexistent.srt"))

    def test_file_path_in_result(self, tmp_path):
        f = _write_file(tmp_path / "test.srt", "content")
        result = compute_subtitle_hash(str(f))
        assert result["file_path"] == str(f)

    def test_hash_matches_bytes_function(self, tmp_path):
        """File hash and in-memory hash must agree for the same content."""
        content = "1\n00:00:01,000 --> 00:00:02,000\nHello\n"
        f = _write_file(tmp_path / "test.srt", content)
        file_hash = compute_subtitle_hash(str(f))["content_hash"]
        bytes_hash = compute_content_hash_from_bytes(content.encode("utf-8"))
        assert file_hash == bytes_hash

    def test_different_cp1251_texts_are_not_duplicates(self, tmp_path):
        # Read as UTF-8 with replacement, both were "������ ���".
        a = tmp_path / "a.ru.srt"
        b = tmp_path / "b.ru.srt"
        a.write_bytes("1\n00:00:01,000 --> 00:00:02,000\nПривет мир\n".encode("cp1251"))
        b.write_bytes("1\n00:00:01,000 --> 00:00:02,000\nДобрый дом\n".encode("cp1251"))
        assert (
            compute_subtitle_hash(str(a))["content_hash"]
            != compute_subtitle_hash(str(b))["content_hash"]
        )

    def test_utf8_bom_file_hash_is_unchanged(self, tmp_path):
        """Stored hashes of UTF-8 files (BOM included) must not move."""
        import hashlib

        f = tmp_path / "bom.de.srt"
        f.write_bytes(b"\xef\xbb\xbf1\r\n00:00:01,000 --> 00:00:02,000\r\nHallo\r\n")
        expected = hashlib.sha256("1\n00:00:01,000 --> 00:00:02,000\nHallo".encode()).hexdigest()
        assert compute_subtitle_hash(str(f))["content_hash"] == expected


# ── TestScanForDuplicates ───────────────────────────────────────────────────


class TestScanForDuplicates:
    """Tests for scan_for_duplicates() — directory scanning + DB storage."""

    def test_invalid_path_returns_error(self):
        result = scan_for_duplicates("/nonexistent/path/xyz")
        assert result["total_scanned"] == 0
        assert "error" in result

    def test_empty_directory(self, tmp_path):
        with patch("db.repositories.cleanup.CleanupRepository") as MockRepo:
            mock_repo = MagicMock()
            mock_repo.get_duplicate_groups.return_value = []
            MockRepo.return_value = mock_repo
            result = scan_for_duplicates(str(tmp_path))
        assert result["total_scanned"] == 0
        assert result["duplicates_found"] == 0
        assert result["groups"] == []

    def test_scans_subtitle_files_only(self, tmp_path):
        _write_file(tmp_path / "episode.srt", "subtitle content")
        _write_file(tmp_path / "episode.mkv", "video content")
        _write_file(tmp_path / "readme.txt", "text content")

        with patch("db.repositories.cleanup.CleanupRepository") as MockRepo:
            mock_repo = MagicMock()
            mock_repo.get_duplicate_groups.return_value = []
            MockRepo.return_value = mock_repo
            result = scan_for_duplicates(str(tmp_path))

        assert result["total_scanned"] == 1
        assert mock_repo.upsert_hash.call_count == 1

    def test_scans_all_subtitle_extensions(self, tmp_path):
        for ext in SUBTITLE_EXTENSIONS:
            _write_file(tmp_path / f"test{ext}", f"content for {ext}")

        with patch("db.repositories.cleanup.CleanupRepository") as MockRepo:
            mock_repo = MagicMock()
            mock_repo.get_duplicate_groups.return_value = []
            MockRepo.return_value = mock_repo
            result = scan_for_duplicates(str(tmp_path))

        assert result["total_scanned"] == len(SUBTITLE_EXTENSIONS)

    def test_scans_subdirectories_recursively(self, tmp_path):
        _write_file(tmp_path / "season1" / "ep1.srt", "content1")
        _write_file(tmp_path / "season2" / "ep2.srt", "content2")
        _write_file(tmp_path / "ep3.ass", "content3")

        with patch("db.repositories.cleanup.CleanupRepository") as MockRepo:
            mock_repo = MagicMock()
            mock_repo.get_duplicate_groups.return_value = []
            MockRepo.return_value = mock_repo
            result = scan_for_duplicates(str(tmp_path))

        assert result["total_scanned"] == 3

    def test_upsert_called_for_each_file(self, tmp_path):
        _write_file(tmp_path / "a.srt", "aaa")
        _write_file(tmp_path / "b.srt", "bbb")

        with patch("db.repositories.cleanup.CleanupRepository") as MockRepo:
            mock_repo = MagicMock()
            mock_repo.get_duplicate_groups.return_value = []
            MockRepo.return_value = mock_repo
            scan_for_duplicates(str(tmp_path))

        assert mock_repo.upsert_hash.call_count == 2

    def test_duplicate_groups_returned(self, tmp_path):
        _write_file(tmp_path / "a.srt", "same")
        _write_file(tmp_path / "b.srt", "same")

        fake_groups = [{"content_hash": "abc", "count": 2, "files": [{}, {}]}]

        with patch("db.repositories.cleanup.CleanupRepository") as MockRepo:
            mock_repo = MagicMock()
            mock_repo.get_duplicate_groups.return_value = fake_groups
            MockRepo.return_value = mock_repo
            result = scan_for_duplicates(str(tmp_path))

        assert result["duplicates_found"] == 2
        assert len(result["groups"]) == 1

    def test_socketio_progress_emitted(self, tmp_path):
        # Create 20+ files to trigger the modulo-10 progress emit
        for i in range(20):
            _write_file(tmp_path / f"sub_{i:02d}.srt", f"content {i}")

        mock_socketio = MagicMock()

        with patch("db.repositories.cleanup.CleanupRepository") as MockRepo:
            mock_repo = MagicMock()
            mock_repo.get_duplicate_groups.return_value = []
            MockRepo.return_value = mock_repo
            scan_for_duplicates(str(tmp_path), socketio=mock_socketio)

        # At least the final 100% emit should fire
        assert mock_socketio.emit.call_count >= 1
        # Check the final emit has percent=100
        final_call = mock_socketio.emit.call_args_list[-1]
        assert final_call[0][0] == "scan_progress"
        assert final_call[0][1]["percent"] == 100.0

    def test_hashing_error_captured(self, tmp_path):
        _write_file(tmp_path / "good.srt", "good content")

        with (
            patch("db.repositories.cleanup.CleanupRepository") as MockRepo,
            patch(
                "dedup_engine.compute_subtitle_hash",
                side_effect=OSError("read error"),
            ),
        ):
            mock_repo = MagicMock()
            mock_repo.get_duplicate_groups.return_value = []
            MockRepo.return_value = mock_repo
            result = scan_for_duplicates(str(tmp_path))

        assert len(result["errors"]) == 1
        assert "read error" in result["errors"][0]["error"]

    def test_upsert_failure_does_not_crash(self, tmp_path):
        _write_file(tmp_path / "test.srt", "content")

        with patch("db.repositories.cleanup.CleanupRepository") as MockRepo:
            mock_repo = MagicMock()
            mock_repo.upsert_hash.side_effect = RuntimeError("DB error")
            mock_repo.get_duplicate_groups.return_value = []
            MockRepo.return_value = mock_repo
            # Should not raise
            result = scan_for_duplicates(str(tmp_path))

        assert result["total_scanned"] == 1


# ── TestDeleteDuplicates ────────────────────────────────────────────────────


class TestDeleteDuplicates:
    """Tests for delete_duplicates() — safe deletion with guards."""

    def _setup_mocks(self, tmp_path):
        """Return common mock patches for delete_duplicates."""
        mock_settings = MagicMock(media_path=str(tmp_path))
        mock_repo = MagicMock()
        return mock_settings, mock_repo

    def test_refuse_when_keep_path_outside_media_path(self, tmp_path):
        """Regression: without this gate the caller could probe arbitrary
        host paths (different errors for "exists" vs "in delete list" vs
        "doesn't exist") AND ask the engine to keep a file outside the
        configured library. Both surfaces are closed by an early
        is_safe_path check on keep_path."""
        # keep_path is OUTSIDE media_path
        outside_keep = str(tmp_path.parent / "outside_keep.srt")
        _write_file(Path(outside_keep), "keep")
        dup = _write_file(tmp_path / "dup.srt", "dup")

        mock_settings, mock_repo = self._setup_mocks(tmp_path)

        with (
            patch("config.get_settings", return_value=mock_settings),
            patch("db.repositories.cleanup.CleanupRepository", return_value=mock_repo),
        ):
            result = delete_duplicates([str(dup)], outside_keep)

        assert result["deleted"] == 0
        assert any(
            "SAFETY" in e and "outside media_path" in e and "keep_path" in e
            for e in result["errors"]
        )
        # The duplicate must NOT have been deleted because the engine
        # rejected the request before iterating file_paths.
        assert dup.exists()
        # The outside file must also still be intact (the engine never
        # touches keep_path; this assertion just guards against future
        # regressions where someone moves the rm into the early path).
        assert Path(outside_keep).exists()

    def test_refuse_when_keep_path_in_deletion_list(self, tmp_path):
        keep = str(tmp_path / "keep.srt")
        _write_file(Path(keep), "keep")

        mock_settings, mock_repo = self._setup_mocks(tmp_path)

        with (
            patch("config.get_settings", return_value=mock_settings),
            patch("db.repositories.cleanup.CleanupRepository", return_value=mock_repo),
        ):
            result = delete_duplicates([keep], keep)

        assert result["deleted"] == 0
        assert any("SAFETY" in e and "keep_path" in e for e in result["errors"])

    def test_refuse_when_keep_path_not_exists(self, tmp_path):
        to_delete = str(tmp_path / "dup.srt")
        keep = str(tmp_path / "nonexistent.srt")
        _write_file(Path(to_delete), "dup")

        mock_settings, mock_repo = self._setup_mocks(tmp_path)

        with (
            patch("config.get_settings", return_value=mock_settings),
            patch("db.repositories.cleanup.CleanupRepository", return_value=mock_repo),
        ):
            result = delete_duplicates([to_delete], keep)

        assert result["deleted"] == 0
        assert any("SAFETY" in e and "does not exist" in e for e in result["errors"])

    def test_successful_deletion(self, tmp_path):
        keep = _write_file(tmp_path / "keep.srt", "content")
        dup = _write_file(tmp_path / "dup.srt", "content")

        mock_settings, mock_repo = self._setup_mocks(tmp_path)

        with (
            patch("config.get_settings", return_value=mock_settings),
            patch("dedup_engine.is_safe_path", return_value=True),
            patch("db.repositories.cleanup.CleanupRepository", return_value=mock_repo),
        ):
            result = delete_duplicates([str(dup)], str(keep))

        assert result["deleted"] == 1
        assert result["bytes_freed"] > 0
        assert not dup.exists()

    def test_multiple_deletions(self, tmp_path):
        keep = _write_file(tmp_path / "keep.srt", "content")
        dup1 = _write_file(tmp_path / "dup1.srt", "content")
        dup2 = _write_file(tmp_path / "dup2.srt", "content")

        mock_settings, mock_repo = self._setup_mocks(tmp_path)

        with (
            patch("config.get_settings", return_value=mock_settings),
            patch("dedup_engine.is_safe_path", return_value=True),
            patch("db.repositories.cleanup.CleanupRepository", return_value=mock_repo),
        ):
            result = delete_duplicates([str(dup1), str(dup2)], str(keep))

        assert result["deleted"] == 2
        assert not dup1.exists()
        assert not dup2.exists()

    def test_skip_symlink(self, tmp_path):
        keep = _write_file(tmp_path / "keep.srt", "content")
        real_file = _write_file(tmp_path / "real.srt", "content")
        link_path = tmp_path / "link.srt"

        try:
            link_path.symlink_to(real_file)
        except OSError:
            pytest.skip("Symlink creation not supported on this platform")

        mock_settings, mock_repo = self._setup_mocks(tmp_path)

        with (
            patch("config.get_settings", return_value=mock_settings),
            patch("dedup_engine.is_safe_path", return_value=True),
            patch("db.repositories.cleanup.CleanupRepository", return_value=mock_repo),
        ):
            result = delete_duplicates([str(link_path)], str(keep))

        assert result["deleted"] == 0
        assert any("symlink" in e.lower() for e in result["errors"])

    def test_symlink_outside_media_path_is_reported_as_outside(self, tmp_path):
        """Containment is checked before islink(): for a host path outside the
        library the answer must not reveal whether it is a symlink."""
        media = tmp_path / "media"
        media.mkdir()
        keep = _write_file(media / "keep.srt", "content")
        outside = tmp_path / "outside"
        outside.mkdir()
        host_file = _write_file(outside / "host.conf", "not a subtitle")
        link_path = outside / "link.srt"
        try:
            link_path.symlink_to(host_file)
        except OSError:
            pytest.skip("Symlink creation not supported on this platform")

        mock_settings, mock_repo = self._setup_mocks(media)

        with (
            patch("config.get_settings", return_value=mock_settings),
            patch("db.repositories.cleanup.CleanupRepository", return_value=mock_repo),
        ):
            result = delete_duplicates([str(link_path)], str(keep))

        assert result["deleted"] == 0
        assert result["errors"] == [f"Skipping path outside media_path: {link_path}"]
        assert keep.exists()

    def test_skip_path_outside_media_path(self, tmp_path):
        keep = _write_file(tmp_path / "keep.srt", "content")
        outside = _write_file(tmp_path / "dup.srt", "content")

        mock_settings, mock_repo = self._setup_mocks(tmp_path)

        with (
            patch("config.get_settings", return_value=mock_settings),
            patch("dedup_engine.is_safe_path", return_value=False),
            patch("db.repositories.cleanup.CleanupRepository", return_value=mock_repo),
        ):
            result = delete_duplicates([str(outside)], str(keep))

        assert result["deleted"] == 0
        assert any("outside media_path" in e for e in result["errors"])
        # File should still exist since it was skipped
        assert outside.exists()

    def test_skip_nonexistent_file(self, tmp_path):
        keep = _write_file(tmp_path / "keep.srt", "content")
        missing = str(tmp_path / "missing.srt")

        mock_settings, mock_repo = self._setup_mocks(tmp_path)

        with (
            patch("config.get_settings", return_value=mock_settings),
            patch("dedup_engine.is_safe_path", return_value=True),
            patch("db.repositories.cleanup.CleanupRepository", return_value=mock_repo),
        ):
            result = delete_duplicates([missing], str(keep))

        assert result["deleted"] == 0
        assert any("not found" in e.lower() for e in result["errors"])

    def test_db_cleanup_called_after_deletion(self, tmp_path):
        keep = _write_file(tmp_path / "keep.srt", "content")
        dup = _write_file(tmp_path / "dup.srt", "content")

        mock_settings, mock_repo = self._setup_mocks(tmp_path)

        with (
            patch("config.get_settings", return_value=mock_settings),
            patch("dedup_engine.is_safe_path", return_value=True),
            patch("db.repositories.cleanup.CleanupRepository", return_value=mock_repo),
        ):
            delete_duplicates([str(dup)], str(keep))

        mock_repo.delete_hashes_by_paths.assert_called_once_with([str(dup)])
        mock_repo.log_cleanup.assert_called_once()
        log_call_kwargs = mock_repo.log_cleanup.call_args
        assert log_call_kwargs[1]["action_type"] == "dedup_delete"
        assert log_call_kwargs[1]["files_deleted"] == 1

    def test_db_cleanup_not_called_when_nothing_deleted(self, tmp_path):
        keep = _write_file(tmp_path / "keep.srt", "content")
        missing = str(tmp_path / "missing.srt")

        mock_settings, mock_repo = self._setup_mocks(tmp_path)

        with (
            patch("config.get_settings", return_value=mock_settings),
            patch("dedup_engine.is_safe_path", return_value=True),
            patch("db.repositories.cleanup.CleanupRepository", return_value=mock_repo),
        ):
            delete_duplicates([missing], str(keep))

        mock_repo.delete_hashes_by_paths.assert_not_called()
        mock_repo.log_cleanup.assert_not_called()

    def test_db_error_during_cleanup_does_not_raise(self, tmp_path):
        keep = _write_file(tmp_path / "keep.srt", "content")
        dup = _write_file(tmp_path / "dup.srt", "content")

        mock_settings, mock_repo = self._setup_mocks(tmp_path)
        mock_repo.delete_hashes_by_paths.side_effect = RuntimeError("DB failure")

        with (
            patch("config.get_settings", return_value=mock_settings),
            patch("dedup_engine.is_safe_path", return_value=True),
            patch("db.repositories.cleanup.CleanupRepository", return_value=mock_repo),
        ):
            # Should not raise despite DB error
            result = delete_duplicates([str(dup)], str(keep))

        assert result["deleted"] == 1

    def test_os_remove_failure_captured(self, tmp_path):
        keep = _write_file(tmp_path / "keep.srt", "content")
        dup = _write_file(tmp_path / "dup.srt", "content")

        mock_settings, mock_repo = self._setup_mocks(tmp_path)

        with (
            patch("config.get_settings", return_value=mock_settings),
            patch("dedup_engine.is_safe_path", return_value=True),
            patch("db.repositories.cleanup.CleanupRepository", return_value=mock_repo),
            patch("dedup_engine.os.remove", side_effect=PermissionError("locked")),
        ):
            result = delete_duplicates([str(dup)], str(keep))

        assert result["deleted"] == 0
        assert any("Failed to delete" in e for e in result["errors"])

    def test_bytes_freed_accumulated(self, tmp_path):
        keep = _write_file(tmp_path / "keep.srt", "content")
        dup1 = _write_file(tmp_path / "dup1.srt", "aaa")
        dup2 = _write_file(tmp_path / "dup2.srt", "bbbbb")

        size1 = os.path.getsize(str(dup1))
        size2 = os.path.getsize(str(dup2))

        mock_settings, mock_repo = self._setup_mocks(tmp_path)

        with (
            patch("config.get_settings", return_value=mock_settings),
            patch("dedup_engine.is_safe_path", return_value=True),
            patch("db.repositories.cleanup.CleanupRepository", return_value=mock_repo),
        ):
            result = delete_duplicates([str(dup1), str(dup2)], str(keep))

        assert result["bytes_freed"] == size1 + size2


# ── TestScanOrphanedSubtitles ──────────────────────────────────────────────


class TestScanOrphanedSubtitles:
    """Tests for scan_orphaned_subtitles() — subtitle files without matching media."""

    def test_invalid_path_returns_empty(self):
        result = scan_orphaned_subtitles("/nonexistent/path/xyz")
        assert result == []

    def test_no_orphans_when_media_exists(self, tmp_path):
        _write_file(tmp_path / "Episode01.mkv", "video")
        _write_file(tmp_path / "Episode01.en.srt", "subtitle")

        result = scan_orphaned_subtitles(str(tmp_path))
        assert result == []

    def test_detects_orphan_without_media(self, tmp_path):
        _write_file(tmp_path / "Episode01.en.srt", "orphaned subtitle")

        result = scan_orphaned_subtitles(str(tmp_path))
        assert len(result) == 1
        assert result[0]["filename"] == "Episode01.en.srt"

    def test_skips_sublarr_trash_subtree(self, tmp_path):
        """Trashed sidecars under .sublarr must NOT be reported as orphans.

        The trash dir holds already-removed sidecars with no matching video
        by design; walking into it counted every one as an orphan (prod:
        27k false positives) and made orphan_files try to re-trash them.
        """
        _write_file(tmp_path / "RealOrphan.en.srt", "live orphan")
        _write_file(tmp_path / ".sublarr" / "trash" / "2026-06-13" / "Old.ara.ass", "trashed")
        _write_file(tmp_path / ".sublarr" / "trash" / "2026-06-13" / "Old.spa.srt", "trashed")

        result = scan_orphaned_subtitles(str(tmp_path))
        paths = [r["path"] for r in result]

        assert len(result) == 1
        assert any("RealOrphan" in p for p in paths)
        assert not any(".sublarr" in p for p in paths)

    def test_orphan_result_fields(self, tmp_path):
        sub = _write_file(tmp_path / "orphan.srt", "content")
        result = scan_orphaned_subtitles(str(tmp_path))

        assert len(result) == 1
        assert result[0]["path"] == str(sub)
        assert result[0]["size"] > 0
        assert result[0]["filename"] == "orphan.srt"
        assert result[0]["directory"] == str(tmp_path)

    def test_subtitle_without_language_tag(self, tmp_path):
        _write_file(tmp_path / "Movie.mkv", "video")
        _write_file(tmp_path / "Movie.srt", "subtitle without lang tag")

        result = scan_orphaned_subtitles(str(tmp_path))
        assert result == []

    def test_subtitle_with_language_tag_matched(self, tmp_path):
        _write_file(tmp_path / "MyMovie.mkv", "video")
        _write_file(tmp_path / "MyMovie.de.srt", "german subtitle")
        _write_file(tmp_path / "MyMovie.en.ass", "english subtitle")

        result = scan_orphaned_subtitles(str(tmp_path))
        assert result == []

    def test_multiple_orphans(self, tmp_path):
        _write_file(tmp_path / "Deleted.en.srt", "orphan1")
        _write_file(tmp_path / "Gone.de.ass", "orphan2")

        result = scan_orphaned_subtitles(str(tmp_path))
        assert len(result) == 2

    def test_non_subtitle_files_ignored(self, tmp_path):
        _write_file(tmp_path / "readme.txt", "not a subtitle")
        _write_file(tmp_path / "image.png", "not a subtitle")

        result = scan_orphaned_subtitles(str(tmp_path))
        assert result == []

    def test_scans_subdirectories(self, tmp_path):
        _write_file(tmp_path / "season1" / "ep1.srt", "orphan")
        _write_file(tmp_path / "season2" / "ep2.mkv", "video")
        _write_file(tmp_path / "season2" / "ep2.srt", "has media")

        result = scan_orphaned_subtitles(str(tmp_path))
        assert len(result) == 1
        assert "season1" in result[0]["path"]

    def test_all_media_extensions_recognized(self, tmp_path):
        """Subtitle should not be orphaned if any media extension matches."""
        for ext in MEDIA_EXTENSIONS:
            subdir = tmp_path / f"test_{ext.lstrip('.')}"
            subdir.mkdir()
            _write_file(subdir / f"video{ext}", "video content")
            _write_file(subdir / "video.en.srt", "subtitle content")

        result = scan_orphaned_subtitles(str(tmp_path))
        assert result == []

    def test_case_insensitive_base_matching(self, tmp_path):
        _write_file(tmp_path / "EPISODE.mkv", "video")
        _write_file(tmp_path / "episode.srt", "subtitle")

        result = scan_orphaned_subtitles(str(tmp_path))
        assert result == []

    def test_three_letter_language_tag_stripped(self, tmp_path):
        _write_file(tmp_path / "MyMovie.mkv", "video")
        _write_file(tmp_path / "MyMovie.jpn.ass", "japanese subtitle")

        result = scan_orphaned_subtitles(str(tmp_path))
        assert result == []

    def test_oserror_on_getsize_returns_zero(self, tmp_path):
        _write_file(tmp_path / "orphan.srt", "content")

        with patch("dedup_engine.os.path.getsize", side_effect=OSError("access denied")):
            result = scan_orphaned_subtitles(str(tmp_path))

        assert len(result) == 1
        assert result[0]["size"] == 0


# ── TestGetDiskSpaceAnalysis ────────────────────────────────────────────────


class TestGetDiskSpaceAnalysis:
    """Tests for get_disk_space_analysis() — delegates to CleanupRepository."""

    def test_delegates_to_repo(self):
        fake_stats = {
            "total_files": 42,
            "total_size_bytes": 1024000,
            "by_format": {"srt": {"count": 30, "size": 600000}},
            "duplicate_count": 5,
            "duplicate_size_bytes": 50000,
            "potential_savings_bytes": 25000,
            "recent_cleanups": [],
        }

        with patch("db.repositories.cleanup.CleanupRepository") as MockRepo:
            mock_repo = MagicMock()
            mock_repo.get_disk_stats.return_value = fake_stats
            MockRepo.return_value = mock_repo
            result = get_disk_space_analysis("/some/path")

        assert result == fake_stats
        mock_repo.get_disk_stats.assert_called_once()


# ── TestModuleConstants ─────────────────────────────────────────────────────


class TestModuleConstants:
    """Verify module-level constants are correct."""

    def test_subtitle_extensions(self):
        assert ".srt" in SUBTITLE_EXTENSIONS
        assert ".ass" in SUBTITLE_EXTENSIONS
        assert ".ssa" in SUBTITLE_EXTENSIONS

    def test_media_extensions(self):
        assert ".mkv" in MEDIA_EXTENSIONS
        assert ".mp4" in MEDIA_EXTENSIONS
        assert ".avi" in MEDIA_EXTENSIONS


def test_orphan_scan_skips_sidecar_trash_batches(tmp_path):
    """Trashed sidecars have no video by design; counting them as orphans lets
    orphan cleanup move them out of their batch and break its manifest."""
    batch = tmp_path / ".sublarr_trash" / "0123abcd"
    batch.mkdir(parents=True)
    (batch / "Old Episode.de.srt").write_text("1\n00:00:01,000 --> 00:00:02,000\nx\n")
    (tmp_path / "Live Orphan.de.srt").write_text("1\n00:00:01,000 --> 00:00:02,000\nx\n")

    orphans = scan_orphaned_subtitles(str(tmp_path))

    paths = {o["path"] for o in orphans}
    assert str(tmp_path / "Live Orphan.de.srt") in paths
    assert not any(".sublarr_trash" in p for p in paths)