soulsync/tests/test_stale_guard.py
BoulderBadgeDad 4d1b9a5639 Artist Sync: guard stale-removal against an unreachable mount + gate it admin-only
The enhanced-tab "Sync" button's stale-removal phase deleted any track whose file
wasn't on disk, with NO guard — so if the music storage was momentarily
unavailable (sleeping NAS, dropped mount, unmounted Docker volume, WSL hiccup),
os.path.exists returned False for EVERY file and one click wiped the whole artist
(tracks + their now-"empty" albums) from the DB. The deep-scan path already had a
50%-stale safety net (#828); this endpoint never got one.

- New core/library/stale_guard.py: is_implausible_stale_removal(missing, total) —
  a tested rule (skip removal when missing > 50% of a >=5-track set), centralised
  so every stale-removal site can share it.
- sync_artist_library: if the guard trips, SKIP removal (delete nothing), return
  removal_skipped + warn; the frontend shows "storage may be offline — skipped"
  instead of silently deleting. Empty-album cleanup now also only runs on the
  non-skipped path and uses `album_id IS NOT NULL` (fixes the NOT IN-with-NULL
  no-op). Frontend also refreshes the view on additions, not just removals.
- @admin_only on the endpoint — it deletes tracks + albums but was ungated, while
  the sibling delete_album endpoint is gated.

Deep scan was already safe (different mechanism: server-diff + its own 50% guard).

Tests: guard unit rules; endpoint skips removal when all files missing (keeps the
tracks), removes only the genuinely-gone few otherwise, and 403s for non-admins.
7 new tests pass.
2026-06-10 19:33:44 -07:00

28 lines
1 KiB
Python

"""Storage-unreachable guard for library stale-removal (artist sync, #828 pattern)."""
from __future__ import annotations
from core.library.stale_guard import is_implausible_stale_removal as g
def test_all_missing_in_a_real_collection_is_blocked():
# 40/40 missing → almost certainly a down mount, not 40 real deletions.
assert g(40, 40) is True
assert g(30, 40) is True # 75% missing — still implausible
def test_a_few_genuinely_missing_files_are_allowed():
assert g(3, 40) is False # normal cleanup of a few gone files
assert g(20, 40) is False # exactly 50% is NOT over the threshold
def test_tiny_sets_are_never_blocked():
# A 2-track artist legitimately losing both must still clean up.
assert g(2, 2) is False
assert g(4, 4) is False # below min_total (5)
def test_edge_inputs():
assert g(0, 0) is False
assert g(0, 100) is False # nothing missing
assert g(5, 5) is True # min_total met, all missing