You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/tests/imports/test_lossy_copy_delete_orig...

215 lines
8.6 KiB

"""Regression tests for lossy_copy.delete_original honoring.
Discord-reported (CAL): with ``lossy_copy.enabled=True``,
``lossy_copy.delete_original=True``, and ``codec=mp3``, downloads
ended up with BOTH the original FLAC AND the converted MP3 in the
target folder. The setting was being read by the pre-move source-
vanished check at ``core/imports/pipeline.py`` but never acted on
during the actual conversion step. Result: a "lossy-only" library
ended up dual-format on every import.
These tests pin the behavior so the regression doesn't return — they
exercise ``create_lossy_copy`` directly with ffmpeg stubbed via
monkeypatch, asserting the original is deleted only when the setting
is enabled and the conversion succeeded.
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from core.imports import file_ops
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@pytest.fixture
def fake_flac(tmp_path: Path) -> Path:
"""A placeholder FLAC file — content doesn't matter, ffmpeg call is stubbed."""
src = tmp_path / "01 - Track.flac"
src.write_bytes(b"FAKE-FLAC-CONTENT")
return src
def _stub_config(monkeypatch, **overrides):
"""Patch the file_ops module's config_manager so each test controls
only the keys it cares about."""
defaults = {
"lossy_copy.enabled": True,
"lossy_copy.codec": "mp3",
"lossy_copy.bitrate": "320",
"lossy_copy.delete_original": False,
}
defaults.update(overrides)
fake_cfg = MagicMock()
fake_cfg.get.side_effect = lambda key, default=None: defaults.get(key, default)
monkeypatch.setattr(file_ops, "config_manager", fake_cfg)
return defaults
def _stub_ffmpeg_success(monkeypatch, fake_flac: Path):
"""Stub shutil.which to report ffmpeg available + subprocess.run to
write a fake MP3 to out_path and return success."""
monkeypatch.setattr(file_ops.shutil, "which", lambda _: "/fake/ffmpeg")
def _fake_run(cmd, **_kwargs):
# cmd[-1] is the out_path (per ffmpeg invocation in create_lossy_copy)
out_path = cmd[-1]
Path(out_path).write_bytes(b"FAKE-MP3-CONTENT")
return SimpleNamespace(returncode=0, stderr="", stdout="")
monkeypatch.setattr(file_ops.subprocess, "run", _fake_run)
# Skip the mutagen tagging step — file is fake bytes, mutagen would
# raise. Accepting the silent-fail path is fine here; tests assert
# on file presence, not tag content.
monkeypatch.setattr(
"mutagen.File",
lambda _path: None,
raising=False,
)
def _stub_ffmpeg_failure(monkeypatch):
"""Stub ffmpeg to return non-zero so the conversion path bails out."""
monkeypatch.setattr(file_ops.shutil, "which", lambda _: "/fake/ffmpeg")
monkeypatch.setattr(
file_ops.subprocess,
"run",
lambda cmd, **kw: SimpleNamespace(returncode=1, stderr="fake ffmpeg error", stdout=""),
)
# ---------------------------------------------------------------------------
# delete_original honored after successful conversion
# ---------------------------------------------------------------------------
class TestDeleteOriginalHonored:
def test_original_flac_removed_when_setting_enabled(self, monkeypatch, fake_flac: Path):
_stub_config(monkeypatch, **{"lossy_copy.delete_original": True})
_stub_ffmpeg_success(monkeypatch, fake_flac)
out_path = file_ops.create_lossy_copy(str(fake_flac))
assert out_path is not None
assert out_path.endswith(".mp3")
assert Path(out_path).exists(), "MP3 should have been written"
assert not fake_flac.exists(), \
"Original FLAC must be removed when lossy_copy.delete_original=True"
def test_original_flac_kept_when_setting_disabled(self, monkeypatch, fake_flac: Path):
_stub_config(monkeypatch, **{"lossy_copy.delete_original": False})
_stub_ffmpeg_success(monkeypatch, fake_flac)
out_path = file_ops.create_lossy_copy(str(fake_flac))
assert out_path is not None
assert Path(out_path).exists()
assert fake_flac.exists(), \
"Original FLAC must survive when lossy_copy.delete_original=False"
def test_default_is_keep_original(self, monkeypatch, fake_flac: Path):
"""When the user never set the option, default = keep original.
Defensive: a missing config value must not silently drop files."""
# Don't override delete_original — picks up the default in _stub_config (False)
_stub_config(monkeypatch)
_stub_ffmpeg_success(monkeypatch, fake_flac)
file_ops.create_lossy_copy(str(fake_flac))
assert fake_flac.exists()
# ---------------------------------------------------------------------------
# delete_original NOT triggered when conversion fails
# ---------------------------------------------------------------------------
class TestDeleteOriginalSkippedOnFailure:
def test_original_kept_when_ffmpeg_fails(self, monkeypatch, fake_flac: Path):
"""If ffmpeg returns non-zero, the conversion is treated as failed
and the original must NOT be deleted (would leave the user with
no audio file at all)."""
_stub_config(monkeypatch, **{"lossy_copy.delete_original": True})
_stub_ffmpeg_failure(monkeypatch)
out_path = file_ops.create_lossy_copy(str(fake_flac))
assert out_path is None, "Conversion failure must return None"
assert fake_flac.exists(), \
"Original FLAC must survive a failed conversion regardless of delete_original"
def test_original_kept_when_lossy_copy_disabled(self, monkeypatch, fake_flac: Path):
"""The function early-returns when lossy_copy.enabled=False — so
delete_original cannot fire even if it's enabled."""
_stub_config(monkeypatch, **{
"lossy_copy.enabled": False,
"lossy_copy.delete_original": True,
})
_stub_ffmpeg_success(monkeypatch, fake_flac)
result = file_ops.create_lossy_copy(str(fake_flac))
assert result is None
assert fake_flac.exists()
# ---------------------------------------------------------------------------
# Defensive paths
# ---------------------------------------------------------------------------
class TestDeleteOriginalDefensive:
def test_does_not_crash_when_original_already_gone(self, monkeypatch, fake_flac: Path):
"""If something else (concurrent worker, dedup cleanup) removed
the original between conversion and deletion, that's not an
error — we just got our wish slightly early."""
_stub_config(monkeypatch, **{"lossy_copy.delete_original": True})
_stub_ffmpeg_success(monkeypatch, fake_flac)
original_remove = os.remove
def _remove_after_unlinking_first(path, *args, **kwargs):
# Simulate the source being gone before the delete call: pre-
# remove on first call, then defer to real os.remove.
if Path(path) == fake_flac and fake_flac.exists():
fake_flac.unlink()
# Now raise FileNotFoundError as os.remove would on a missing path
raise FileNotFoundError(2, "No such file or directory", str(path))
return original_remove(path, *args, **kwargs)
monkeypatch.setattr(file_ops.os, "remove", _remove_after_unlinking_first)
out_path = file_ops.create_lossy_copy(str(fake_flac))
assert out_path is not None
assert Path(out_path).exists()
assert not fake_flac.exists()
def test_handles_oserror_during_delete_without_propagating(self, monkeypatch, fake_flac: Path):
"""If the actual unlink fails (permission error, locked file, FS
full), the conversion is still considered successful — the lossy
copy already exists. We log the error but return the out_path so
the import pipeline can continue. The original is left in place
for the user to clean up manually."""
_stub_config(monkeypatch, **{"lossy_copy.delete_original": True})
_stub_ffmpeg_success(monkeypatch, fake_flac)
def _failing_remove(path, *args, **kwargs):
raise PermissionError(13, "Permission denied", str(path))
monkeypatch.setattr(file_ops.os, "remove", _failing_remove)
out_path = file_ops.create_lossy_copy(str(fake_flac))
assert out_path is not None, "Failed delete must not break conversion return value"
assert Path(out_path).exists()
assert fake_flac.exists(), "Failed delete leaves the original in place"