soulsync/tests/imports/test_rematch_hints.py
BoulderBadgeDad dbd8278a14 #889 Phase 1: re-identify hint store (DB table + pure create/find/consume seam)
A single-use, user-designated 'which release does this track belong to' answer.
Written when the user picks a release in the Re-identify modal and the file is
staged; the import flow will read it at the top of matching and consume it.

- rematch_hints table (additive, IF NOT EXISTS + indexes) keyed on staged_path
  with content_hash as a rename-proof fallback.
- core/imports/rematch_hints.py: pure DB seam over an injected cursor
  (create/find/consume/list) + a cheap size+head+tail file fingerprint.
- exempt_dedup baked into the hint (a re-identify must bypass dedup-skip);
  replace_track_id carried for deferred post-success cleanup.

Inert until wired (Phase 5) — nothing calls it yet. 9 seam tests.
2026-06-18 15:15:41 -07:00

155 lines
5.1 KiB
Python

"""#889 Phase 1: the re-identify hint store — create / find / consume.
The hint is the single-use, user-designated "which release" answer the import
flow reads at the top of matching. These lock down: a hint round-trips, it's
found by staged path, found by content_hash when the path missed (rename-proof),
found by basename when the dir changed, consumed exactly once, and that a
consumed hint is never handed back.
"""
from __future__ import annotations
import sqlite3
import pytest
from core.imports.rematch_hints import (
RematchHint,
consume_hint,
create_hint,
find_hint_for_file,
list_pending_hints,
quick_file_signature,
)
# The slice of the real schema this module touches (kept in sync with
# database/music_database.py's rematch_hints CREATE).
_SCHEMA = """
CREATE TABLE rematch_hints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
staged_path TEXT NOT NULL,
content_hash TEXT,
source TEXT NOT NULL,
isrc TEXT,
track_id TEXT,
album_id TEXT,
artist_id TEXT,
track_title TEXT,
album_name TEXT,
artist_name TEXT,
album_type TEXT,
track_number INTEGER,
disc_number INTEGER,
replace_track_id INTEGER,
exempt_dedup INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
consumed_at TIMESTAMP
)
"""
@pytest.fixture
def cur():
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.executescript(_SCHEMA)
yield conn.cursor()
conn.close()
def _hint(**kw):
base = dict(
staged_path="/staging/Song.flac",
source="spotify",
isrc="USABC1234567",
track_id="trk_1",
album_id="alb_album1",
artist_id="art_1",
track_title="Song",
album_name="Album1",
artist_name="Artist",
album_type="album",
track_number=5,
disc_number=1,
replace_track_id=42,
)
base.update(kw)
return RematchHint(**base)
def test_create_and_find_by_path_roundtrips(cur):
new_id = create_hint(cur, _hint())
assert new_id > 0
got = find_hint_for_file(cur, "/staging/Song.flac")
assert got is not None
assert got.id == new_id
assert got.album_id == "alb_album1" and got.album_type == "album"
assert got.isrc == "USABC1234567"
assert got.track_number == 5 and got.disc_number == 1
assert got.replace_track_id == 42
assert got.exempt_dedup is True # always set for a user-designated re-identify
assert got.status == "pending"
def test_find_by_content_hash_when_path_missed(cur):
create_hint(cur, _hint(content_hash="deadbeef"))
# Watcher renamed/moved the file → path lookup misses, hash rescues it.
assert find_hint_for_file(cur, "/totally/different.flac") is None
got = find_hint_for_file(cur, "/totally/different.flac", content_hash="deadbeef")
assert got is not None and got.album_name == "Album1"
def test_find_by_basename_when_dir_changed(cur):
create_hint(cur, _hint(staged_path="/staging/in/Song.flac"))
# Same filename, different directory (watcher moved it deeper).
got = find_hint_for_file(cur, "/staging/processing/Song.flac")
assert got is not None and got.track_id == "trk_1"
def test_consume_is_single_use(cur):
new_id = create_hint(cur, _hint())
assert find_hint_for_file(cur, "/staging/Song.flac") is not None
consume_hint(cur, new_id)
# Consumed → never handed back, by path or by hash.
assert find_hint_for_file(cur, "/staging/Song.flac") is None
assert find_hint_for_file(cur, "/x", content_hash=None) is None
def test_list_pending_excludes_consumed(cur):
a = create_hint(cur, _hint(staged_path="/staging/A.flac"))
create_hint(cur, _hint(staged_path="/staging/B.flac"))
assert len(list_pending_hints(cur)) == 2
consume_hint(cur, a)
pend = list_pending_hints(cur)
assert len(pend) == 1 and pend[0].staged_path == "/staging/B.flac"
def test_newest_pending_wins_on_duplicate_path(cur):
create_hint(cur, _hint(album_id="alb_old"))
create_hint(cur, _hint(album_id="alb_new")) # user re-picked for the same file
got = find_hint_for_file(cur, "/staging/Song.flac")
assert got.album_id == "alb_new"
def test_exempt_dedup_false_roundtrips(cur):
create_hint(cur, _hint(staged_path="/staging/Keep.flac", exempt_dedup=False))
got = find_hint_for_file(cur, "/staging/Keep.flac")
assert got.exempt_dedup is False
# ── content fingerprint ───────────────────────────────────────────────────────
def test_quick_file_signature_stable_and_distinct(tmp_path):
a = tmp_path / "a.bin"
b = tmp_path / "b.bin"
a.write_bytes(b"hello world" * 1000)
b.write_bytes(b"goodbye moon" * 1000)
sig_a1 = quick_file_signature(str(a))
sig_a2 = quick_file_signature(str(a))
sig_b = quick_file_signature(str(b))
assert sig_a1 and sig_a1 == sig_a2 # stable
assert sig_a1 != sig_b # distinct content → distinct sig
def test_quick_file_signature_missing_file_is_none():
assert quick_file_signature("/no/such/file.flac") is None