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.
408 lines
14 KiB
408 lines
14 KiB
"""Tests for the HiFi instance CRUD helpers on ``MusicDatabase``:
|
|
|
|
- ``get_hifi_instances()`` — returns enabled instances ordered by priority
|
|
- ``get_all_hifi_instances()`` — returns all instances (enabled + disabled)
|
|
- ``add_hifi_instance(url, priority)`` — inserts a new instance
|
|
- ``remove_hifi_instance(url)`` — deletes an instance by URL
|
|
- ``toggle_hifi_instance(url, enabled)`` — enables/disables an instance
|
|
- ``reorder_hifi_instances(urls)`` — updates priority ordering
|
|
- ``seed_hifi_instances(default_urls)`` — seeds defaults when table is empty
|
|
|
|
These are isolated DB-method tests so the SQL itself is verified
|
|
without spinning up Flask or any HiFi client.
|
|
"""
|
|
|
|
import sqlite3
|
|
import sys
|
|
import types
|
|
|
|
import pytest
|
|
|
|
|
|
# ── stubs (same shape used elsewhere in the test suite) ───────────────────
|
|
if "spotipy" not in sys.modules:
|
|
spotipy = types.ModuleType("spotipy")
|
|
spotipy.Spotify = object
|
|
oauth2 = types.ModuleType("spotipy.oauth2")
|
|
oauth2.SpotifyOAuth = object
|
|
oauth2.SpotifyClientCredentials = object
|
|
spotipy.oauth2 = oauth2
|
|
sys.modules["spotipy"] = spotipy
|
|
sys.modules["spotipy.oauth2"] = oauth2
|
|
|
|
if "config.settings" not in sys.modules:
|
|
config_pkg = types.ModuleType("config")
|
|
settings_mod = types.ModuleType("config.settings")
|
|
|
|
class _DummyConfigManager:
|
|
def get(self, key, default=None):
|
|
return default
|
|
|
|
def get_active_media_server(self):
|
|
return "primary"
|
|
|
|
settings_mod.config_manager = _DummyConfigManager()
|
|
config_pkg.settings = settings_mod
|
|
sys.modules["config"] = config_pkg
|
|
sys.modules["config.settings"] = settings_mod
|
|
|
|
|
|
from database.music_database import MusicDatabase # noqa: E402
|
|
|
|
|
|
# ── helpers ───────────────────────────────────────────────────────────────
|
|
|
|
class _InMemoryDB(MusicDatabase):
|
|
"""MusicDatabase that uses an in-memory sqlite that survives across
|
|
`_get_connection()` calls."""
|
|
|
|
def __init__(self):
|
|
self._conn = sqlite3.connect(":memory:")
|
|
self._conn.row_factory = sqlite3.Row
|
|
self._conn.execute("""
|
|
CREATE TABLE hifi_instances (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
url TEXT NOT NULL UNIQUE,
|
|
priority INTEGER NOT NULL DEFAULT 0,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
""")
|
|
self._conn.commit()
|
|
|
|
def _get_connection(self):
|
|
return _NonClosingConn(self._conn)
|
|
|
|
|
|
class _NonClosingConn:
|
|
"""Wraps the shared sqlite connection so `with db._get_connection()
|
|
as conn:` doesn't close the underlying handle between calls."""
|
|
def __init__(self, real):
|
|
self._real = real
|
|
|
|
def cursor(self):
|
|
return self._real.cursor()
|
|
|
|
def commit(self):
|
|
return self._real.commit()
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def db():
|
|
return _InMemoryDB()
|
|
|
|
|
|
def _seed(db, *, instances=()):
|
|
"""Seed hifi_instances rows. Each tuple: (url, priority, enabled)."""
|
|
cur = db._conn.cursor()
|
|
for url, priority, enabled in instances:
|
|
cur.execute(
|
|
"INSERT INTO hifi_instances (url, priority, enabled) VALUES (?, ?, ?)",
|
|
(url, priority, enabled),
|
|
)
|
|
db._conn.commit()
|
|
|
|
|
|
# ── get_hifi_instances ────────────────────────────────────────────────────
|
|
|
|
|
|
def test_get_hifi_instances_returns_enabled_ordered_by_priority(db):
|
|
_seed(db, instances=[
|
|
("http://b.com", 10, 1),
|
|
("http://a.com", 5, 1),
|
|
("http://c.com", 1, 1),
|
|
])
|
|
rows = db.get_hifi_instances()
|
|
assert [r["url"] for r in rows] == ["http://c.com", "http://a.com", "http://b.com"]
|
|
assert [r["priority"] for r in rows] == [1, 5, 10]
|
|
|
|
|
|
def test_get_hifi_instances_excludes_disabled(db):
|
|
_seed(db, instances=[
|
|
("http://a.com", 0, 1),
|
|
("http://b.com", 1, 0),
|
|
("http://c.com", 2, 1),
|
|
])
|
|
rows = db.get_hifi_instances()
|
|
assert {r["url"] for r in rows} == {"http://a.com", "http://c.com"}
|
|
|
|
|
|
def test_get_hifi_instances_returns_empty_when_no_rows(db):
|
|
assert db.get_hifi_instances() == []
|
|
|
|
|
|
def test_get_hifi_instances_tiebreaks_on_id(db):
|
|
"""Same priority → ordered by insertion order (autoincrement id)."""
|
|
_seed(db, instances=[
|
|
("http://first.com", 0, 1),
|
|
("http://second.com", 0, 1),
|
|
("http://third.com", 0, 1),
|
|
])
|
|
rows = db.get_hifi_instances()
|
|
assert [r["url"] for r in rows] == ["http://first.com", "http://second.com", "http://third.com"]
|
|
|
|
|
|
# ── get_all_hifi_instances ────────────────────────────────────────────────
|
|
|
|
|
|
def test_get_all_hifi_instances_returns_all_including_disabled(db):
|
|
_seed(db, instances=[
|
|
("http://a.com", 0, 1),
|
|
("http://b.com", 1, 0),
|
|
])
|
|
rows = db.get_all_hifi_instances()
|
|
assert {r["url"] for r in rows} == {"http://a.com", "http://b.com"}
|
|
|
|
|
|
def test_get_all_hifi_instances_ordered_by_priority(db):
|
|
_seed(db, instances=[
|
|
("http://c.com", 20, 0),
|
|
("http://a.com", 0, 1),
|
|
("http://b.com", 10, 1),
|
|
])
|
|
rows = db.get_all_hifi_instances()
|
|
assert [r["url"] for r in rows] == ["http://a.com", "http://b.com", "http://c.com"]
|
|
|
|
|
|
def test_get_all_hifi_instances_returns_empty_when_no_rows(db):
|
|
assert db.get_all_hifi_instances() == []
|
|
|
|
|
|
# ── add_hifi_instance ─────────────────────────────────────────────────────
|
|
|
|
|
|
def test_add_hifi_instance_returns_true_on_insert(db):
|
|
assert db.add_hifi_instance("http://new.com", priority=3) is True
|
|
rows = db.get_all_hifi_instances()
|
|
assert len(rows) == 1
|
|
assert rows[0]["url"] == "http://new.com"
|
|
assert rows[0]["priority"] == 3
|
|
assert rows[0]["enabled"] == 1
|
|
|
|
|
|
def test_add_hifi_instance_returns_false_on_duplicate(db):
|
|
_seed(db, instances=[("http://dup.com", 0, 1)])
|
|
# INSERT OR IGNORE — should not raise, but return False (rowcount == 0)
|
|
assert db.add_hifi_instance("http://dup.com", priority=5) is False
|
|
rows = db.get_all_hifi_instances()
|
|
assert len(rows) == 1
|
|
|
|
|
|
def test_add_hifi_instance_default_priority(db):
|
|
db.add_hifi_instance("http://x.com")
|
|
row = db.get_all_hifi_instances()[0]
|
|
assert row["priority"] == 0
|
|
|
|
|
|
# ── remove_hifi_instance ──────────────────────────────────────────────────
|
|
|
|
|
|
def test_remove_hifi_instance_returns_true_on_delete(db):
|
|
_seed(db, instances=[("http://go.com", 0, 1)])
|
|
assert db.remove_hifi_instance("http://go.com") is True
|
|
assert db.get_all_hifi_instances() == []
|
|
|
|
|
|
def test_remove_hifi_instance_returns_false_when_not_found(db):
|
|
assert db.remove_hifi_instance("http://missing.com") is False
|
|
|
|
|
|
def test_remove_hifi_instance_only_removes_matching_url(db):
|
|
_seed(db, instances=[
|
|
("http://keep.com", 0, 1),
|
|
("http://delete.com", 1, 1),
|
|
])
|
|
db.remove_hifi_instance("http://delete.com")
|
|
rows = db.get_all_hifi_instances()
|
|
assert len(rows) == 1
|
|
assert rows[0]["url"] == "http://keep.com"
|
|
|
|
|
|
# ── toggle_hifi_instance ──────────────────────────────────────────────────
|
|
|
|
|
|
def test_toggle_hifi_instance_disable(db):
|
|
_seed(db, instances=[("http://x.com", 0, 1)])
|
|
assert db.toggle_hifi_instance("http://x.com", enabled=False) is True
|
|
row = db.get_all_hifi_instances()[0]
|
|
assert row["enabled"] == 0
|
|
|
|
|
|
def test_toggle_hifi_instance_enable(db):
|
|
_seed(db, instances=[("http://x.com", 0, 0)])
|
|
assert db.toggle_hifi_instance("http://x.com", enabled=True) is True
|
|
row = db.get_all_hifi_instances()[0]
|
|
assert row["enabled"] == 1
|
|
|
|
|
|
def test_toggle_hifi_instance_returns_false_when_not_found(db):
|
|
assert db.toggle_hifi_instance("http://missing.com", enabled=True) is False
|
|
|
|
|
|
def test_toggle_hifi_instance_noop_when_already_set(db):
|
|
"""Toggling to the same value should still return True (row matched)."""
|
|
_seed(db, instances=[("http://x.com", 0, 1)])
|
|
# SQLite rowcount for UPDATE is 1 even if value didn't change
|
|
assert db.toggle_hifi_instance("http://x.com", enabled=True) is True
|
|
|
|
|
|
# ── reorder_hifi_instances ────────────────────────────────────────────────
|
|
|
|
|
|
def test_reorder_hifi_instances_updates_priorities(db):
|
|
_seed(db, instances=[
|
|
("http://a.com", 0, 1),
|
|
("http://b.com", 1, 1),
|
|
("http://c.com", 2, 1),
|
|
])
|
|
db.reorder_hifi_instances(["http://c.com", "http://a.com", "http://b.com"])
|
|
rows = db.get_all_hifi_instances()
|
|
by_url = {r["url"]: r["priority"] for r in rows}
|
|
assert by_url == {"http://c.com": 0, "http://a.com": 1, "http://b.com": 2}
|
|
|
|
|
|
def test_reorder_hifi_instances_returns_true_on_empty_list(db):
|
|
assert db.reorder_hifi_instances([]) is True
|
|
|
|
|
|
def test_reorder_hifi_instances_returns_false_with_unknown_urls(db):
|
|
"""Reorder should fail when any URL doesn't exist."""
|
|
_seed(db, instances=[("http://a.com", 0, 1)])
|
|
assert db.reorder_hifi_instances(["http://a.com", "http://phantom.com"]) is False
|
|
|
|
|
|
# ── seed_hifi_instances ───────────────────────────────────────────────────
|
|
|
|
|
|
def test_seed_hifi_instances_inserts_when_empty(db):
|
|
db.seed_hifi_instances(["http://a.com", "http://b.com"])
|
|
rows = db.get_all_hifi_instances()
|
|
assert len(rows) == 2
|
|
by_url = {r["url"]: r["priority"] for r in rows}
|
|
assert by_url == {"http://a.com": 0, "http://b.com": 1}
|
|
|
|
|
|
def test_seed_hifi_instances_does_nothing_when_table_has_rows(db):
|
|
_seed(db, instances=[("http://existing.com", 0, 1)])
|
|
db.seed_hifi_instances(["http://new.com"])
|
|
rows = db.get_all_hifi_instances()
|
|
assert len(rows) == 1
|
|
assert rows[0]["url"] == "http://existing.com"
|
|
|
|
|
|
def test_seed_hifi_instances_does_not_duplicate_on_reseed(db):
|
|
db.seed_hifi_instances(["http://a.com"])
|
|
db.seed_hifi_instances(["http://a.com"])
|
|
rows = db.get_all_hifi_instances()
|
|
assert len(rows) == 1
|
|
|
|
|
|
# ── lazy-create recovery (issue #503) ────────────────────────────────────
|
|
# When the bulk ``_initialize_database`` rolls back due to a brittle
|
|
# migration step, ``hifi_instances`` would normally be missing on the
|
|
# user's DB. The CRUD methods now ensure the table exists immediately
|
|
# before operating, so the operation succeeds rather than throwing
|
|
# "no such table: hifi_instances".
|
|
|
|
|
|
def _db_without_hifi_table():
|
|
"""Returns a MusicDatabase with NO hifi_instances table — simulates
|
|
the broken-init state where ``_initialize_database`` rolled back."""
|
|
conn = sqlite3.connect(":memory:")
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
class _NoTableDB(MusicDatabase):
|
|
def __init__(self):
|
|
self._conn = conn
|
|
|
|
def _get_connection(self):
|
|
return _NonClosingConn(self._conn)
|
|
|
|
return _NoTableDB()
|
|
|
|
|
|
def _hifi_table_exists(db):
|
|
cur = db._conn.cursor()
|
|
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='hifi_instances'")
|
|
return cur.fetchone() is not None
|
|
|
|
|
|
def test_add_hifi_instance_creates_table_when_missing():
|
|
"""Pin issue #503 fix: add_hifi_instance must lazily create the
|
|
table when init failed to land it. Pre-fix this raised
|
|
``no such table: hifi_instances`` and the user couldn't add any
|
|
HiFi instances at all."""
|
|
db = _db_without_hifi_table()
|
|
assert not _hifi_table_exists(db)
|
|
|
|
result = db.add_hifi_instance("http://recovery.com", priority=0)
|
|
|
|
assert result is True
|
|
assert _hifi_table_exists(db)
|
|
rows = db.get_all_hifi_instances()
|
|
assert len(rows) == 1
|
|
assert rows[0]["url"] == "http://recovery.com"
|
|
|
|
|
|
def test_get_hifi_instances_creates_table_when_missing():
|
|
"""Empty result instead of OperationalError — read methods
|
|
self-heal too."""
|
|
db = _db_without_hifi_table()
|
|
assert not _hifi_table_exists(db)
|
|
|
|
rows = db.get_hifi_instances()
|
|
|
|
assert rows == []
|
|
assert _hifi_table_exists(db)
|
|
|
|
|
|
def test_get_all_hifi_instances_creates_table_when_missing():
|
|
db = _db_without_hifi_table()
|
|
rows = db.get_all_hifi_instances()
|
|
assert rows == []
|
|
assert _hifi_table_exists(db)
|
|
|
|
|
|
def test_remove_hifi_instance_creates_table_when_missing():
|
|
"""Removing from a missing table is a no-op (returns False) —
|
|
doesn't crash."""
|
|
db = _db_without_hifi_table()
|
|
result = db.remove_hifi_instance("http://x.com")
|
|
assert result is False
|
|
assert _hifi_table_exists(db)
|
|
|
|
|
|
def test_toggle_hifi_instance_creates_table_when_missing():
|
|
db = _db_without_hifi_table()
|
|
result = db.toggle_hifi_instance("http://x.com", enabled=True)
|
|
assert result is False # Nothing to toggle
|
|
assert _hifi_table_exists(db)
|
|
|
|
|
|
def test_reorder_hifi_instances_creates_table_when_missing():
|
|
"""Reorder with missing entries returns False (the existing
|
|
contract — caller-supplied URLs not present)."""
|
|
db = _db_without_hifi_table()
|
|
result = db.reorder_hifi_instances(["http://x.com"])
|
|
assert result is False # No matching rows
|
|
assert _hifi_table_exists(db)
|
|
|
|
|
|
def test_seed_hifi_instances_creates_table_when_missing():
|
|
"""Seeding into a missing table works — table is created, then
|
|
defaults inserted."""
|
|
db = _db_without_hifi_table()
|
|
db.seed_hifi_instances(["http://default-a.com", "http://default-b.com"])
|
|
rows = db.get_all_hifi_instances()
|
|
assert len(rows) == 2
|
|
assert _hifi_table_exists(db)
|