mirror of https://github.com/Nezreka/SoulSync.git
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.
215 lines
8.6 KiB
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"
|