soulsync/tests/test_orphan_file_detector.py
BoulderBadgeDad 94a0070fa8 Orphan detector: hard-bail on a mass-orphan flood instead of warn-only
A DB<->filesystem path mismatch (Docker volume change, remount, Music
Paths unset for the container) makes EVERY library file fail to resolve
to a DB track, so the orphan detector flags the whole library as
orphaned. The mass-orphan check only logged a warning and then created
the findings anyway — so a user batch-applying 'move to staging' or
'delete' would relocate or wipe their entire library.

Make it a hard skip (create zero findings) like the dead-file cleaner
and stale-removal paths already do (#828). Centralise the predicate as
is_implausible_orphan_flood() alongside is_implausible_stale_removal()
so the rule lives in one tested place. Small genuine orphan sets still
surface unchanged — only an implausibly large flood (>50% and >20) is
suppressed.

Tests: seam cases for the new predicate + scan-level regressions (mass
mismatch -> 0 findings; small genuine set -> still reported).
2026-06-12 08:15:23 -07:00

139 lines
4.6 KiB
Python

"""Regression tests for the orphan file detector."""
from __future__ import annotations
import sqlite3
from pathlib import Path
from core.repair_jobs.base import JobContext
from core.repair_jobs.orphan_file_detector import OrphanFileDetectorJob
class _DB:
def __init__(self, path: Path) -> None:
self.path = path
def _get_connection(self):
return sqlite3.connect(self.path)
def _seed_library(db_path: Path) -> None:
conn = sqlite3.connect(db_path)
try:
conn.executescript(
"""
CREATE TABLE artists (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE albums (
id INTEGER PRIMARY KEY,
artist_id INTEGER NOT NULL,
title TEXT NOT NULL
);
CREATE TABLE tracks (
id INTEGER PRIMARY KEY,
album_id INTEGER NOT NULL,
artist_id INTEGER NOT NULL,
title TEXT NOT NULL,
file_path TEXT
);
INSERT INTO artists (id, name) VALUES
(1, 'Clouddead89'),
(2, 'Featured Artist');
INSERT INTO albums (id, artist_id, title) VALUES
(10, 1, 'Perfect Match Error');
INSERT INTO tracks (id, album_id, artist_id, title, file_path) VALUES
(100, 10, 2, 'Perfect Match', '/old/prefix/elsewhere.mp3');
"""
)
conn.commit()
finally:
conn.close()
def test_mass_orphan_path_mismatch_creates_no_findings(tmp_path: Path) -> None:
"""The "transferred to staging" footgun: when the DB's stored paths no longer
match the filesystem (remount / Docker volume change) EVERY file looks
orphaned. The detector must create NO findings then — otherwise a user
batch-applying "move to staging" relocates their whole library. Mirrors the
hard skip the stale-removal paths use.
"""
db_path = tmp_path / "library.sqlite"
_seed_library(db_path) # DB tracks live under /old/prefix/... — nothing on disk matches
# Drop 30 untracked files (> the 20 absolute floor, and 100% > 50%).
music = tmp_path / "Some Artist" / "Some Album"
music.mkdir(parents=True)
for i in range(30):
(music / f"{i:02d} - Track {i}.mp3").write_bytes(b"unreadable tags; no DB match")
findings = []
context = JobContext(
db=_DB(db_path),
transfer_folder=str(tmp_path),
config_manager=None,
create_finding=lambda **kwargs: findings.append(kwargs) or True,
)
result = OrphanFileDetectorJob().scan(context)
assert result.scanned == 30
assert result.findings_created == 0
assert findings == [] # hard skip — not even flagged as warnings
def test_small_orphan_set_still_surfaces(tmp_path: Path) -> None:
"""Below the absolute floor, genuine orphans must still be reported — the
guard only suppresses an implausibly large flood, not normal stray files.
"""
db_path = tmp_path / "library.sqlite"
_seed_library(db_path)
music = tmp_path / "Stray" / "Files"
music.mkdir(parents=True)
for i in range(3): # 3 orphans — under the 20-file floor
(music / f"{i:02d} - Stray {i}.mp3").write_bytes(b"no DB match")
findings = []
context = JobContext(
db=_DB(db_path),
transfer_folder=str(tmp_path),
config_manager=None,
create_finding=lambda **kwargs: findings.append(kwargs) or True,
)
result = OrphanFileDetectorJob().scan(context)
assert result.scanned == 3
assert result.findings_created == 3
assert all(f['finding_type'] == 'orphan_file' for f in findings)
def test_orphan_detector_accepts_picard_albumartist_folder_match(tmp_path: Path) -> None:
"""Picard paths use albumartist/album (year)/track - title.
Even when the DB track artist is a featured artist, the album artist
folder should be enough to recognize the file as tracked.
"""
db_path = tmp_path / "library.sqlite"
_seed_library(db_path)
transfer = tmp_path / "Clouddead89" / "Perfect Match Error (2026)"
transfer.mkdir(parents=True)
audio_path = transfer / "01 - Perfect Match.mp3"
audio_path.write_bytes(b"not a real mp3; filename fallback handles this")
findings = []
context = JobContext(
db=_DB(db_path),
transfer_folder=str(tmp_path),
config_manager=None,
create_finding=lambda **kwargs: findings.append(kwargs) or True,
)
result = OrphanFileDetectorJob().scan(context)
assert result.scanned == 1
assert result.findings_created == 0
assert findings == []