feat(service-storage-harness): slice 4 — cascade-delete wiring + integration test
Wire ServiceDataHarness.cascade_delete into SettingsStore.delete_service (best-effort try/except, logs on failure). Fix migration runner to also catch 'no such table' on fresh DBs (ALTER TABLE before init_schema). Integration test proves end-to-end cascade across both concerns (qBit samples + media items) with multi-instance preservation. Backend: 322 pytest pass, ruff clean.
This commit is contained in:
@@ -95,8 +95,9 @@ class ServiceDataHarness:
|
|||||||
try:
|
try:
|
||||||
conn.execute(stmt)
|
conn.execute(stmt)
|
||||||
except sqlite3.OperationalError as exc:
|
except sqlite3.OperationalError as exc:
|
||||||
if "duplicate column name" in str(exc).lower():
|
lowered = str(exc).lower()
|
||||||
logger.debug("Skipping already-applied migration: %s", stmt[:80])
|
if "duplicate column name" in lowered or "no such table" in lowered:
|
||||||
|
logger.debug("Skipping migration (already applied or table absent): %s", stmt[:80])
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
@@ -1740,7 +1740,7 @@ class SettingsStore:
|
|||||||
return self.get_service(service["id"]) or service
|
return self.get_service(service["id"]) or service
|
||||||
|
|
||||||
def delete_service(self, service_id: str) -> None:
|
def delete_service(self, service_id: str) -> None:
|
||||||
"""Delete a service and cascade-delete widgets referencing it."""
|
"""Delete a service and cascade-delete widgets + harness data."""
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
# The service_id column on dashboard_widgets is added in a later
|
# The service_id column on dashboard_widgets is added in a later
|
||||||
@@ -1753,6 +1753,15 @@ class SettingsStore:
|
|||||||
)
|
)
|
||||||
conn.execute("DELETE FROM services WHERE id = ?", (service_id,))
|
conn.execute("DELETE FROM services WHERE id = ?", (service_id,))
|
||||||
|
|
||||||
|
# Cascade-delete harness-managed data (best-effort: the service row is
|
||||||
|
# already removed; data cleanup must not block service deletion).
|
||||||
|
try:
|
||||||
|
from media_library_viewer_api.services.service_data import get_service_data_harness
|
||||||
|
|
||||||
|
get_service_data_harness().cascade_delete(service_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to cascade-delete harness data for service %s", service_id)
|
||||||
|
|
||||||
def record_service_task_run(self, payload: dict[str, Any]) -> dict[str, Any]:
|
def record_service_task_run(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Append a service task run history row."""
|
"""Append a service task run history row."""
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
|
|||||||
@@ -228,16 +228,20 @@ class TestMediaIndexQuery:
|
|||||||
|
|
||||||
class TestMediaIndexReplace:
|
class TestMediaIndexReplace:
|
||||||
def test_replace_clears_old(self, index):
|
def test_replace_clears_old(self, index):
|
||||||
index.replace_items([
|
index.replace_items(
|
||||||
{"id": "x", "title": "Old", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
[
|
||||||
])
|
{"id": "x", "title": "Old", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
||||||
|
]
|
||||||
|
)
|
||||||
status = index.status()
|
status = index.status()
|
||||||
assert status.item_count == 1
|
assert status.item_count == 1
|
||||||
|
|
||||||
index.replace_items([
|
index.replace_items(
|
||||||
{"id": "y", "title": "New1", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
[
|
||||||
{"id": "z", "title": "New2", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
{"id": "y", "title": "New1", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
||||||
])
|
{"id": "z", "title": "New2", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
||||||
|
]
|
||||||
|
)
|
||||||
status = index.status()
|
status = index.status()
|
||||||
assert status.item_count == 2
|
assert status.item_count == 2
|
||||||
|
|
||||||
@@ -335,15 +339,11 @@ class TestMediaIndexBuildPaths:
|
|||||||
self.calls.append(kwargs.get("start_index", 0))
|
self.calls.append(kwargs.get("start_index", 0))
|
||||||
if kwargs.get("start_index", 0) == 0:
|
if kwargs.get("start_index", 0) == 0:
|
||||||
return {
|
return {
|
||||||
"Items": [
|
"Items": [{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}],
|
||||||
{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}
|
|
||||||
],
|
|
||||||
"TotalRecordCount": 2,
|
"TotalRecordCount": 2,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"Items": [
|
"Items": [{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}],
|
||||||
{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}
|
|
||||||
],
|
|
||||||
"TotalRecordCount": 2,
|
"TotalRecordCount": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,15 +379,11 @@ class TestMediaIndexBuildPaths:
|
|||||||
self.calls.append(kwargs.get("start_index", 0))
|
self.calls.append(kwargs.get("start_index", 0))
|
||||||
if kwargs.get("start_index", 0) == 0:
|
if kwargs.get("start_index", 0) == 0:
|
||||||
return {
|
return {
|
||||||
"Items": [
|
"Items": [{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}],
|
||||||
{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}
|
|
||||||
],
|
|
||||||
"TotalRecordCount": 2,
|
"TotalRecordCount": 2,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"Items": [
|
"Items": [{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}],
|
||||||
{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}
|
|
||||||
],
|
|
||||||
"TotalRecordCount": 2,
|
"TotalRecordCount": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -396,6 +396,77 @@ def test_delete_service_cascades_to_widgets(client, tmp_path):
|
|||||||
assert int(remaining[0]) == 0
|
assert int(remaining[0]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Harness cascade-delete (Slice 4)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cascade_delete_removes_harness_data_across_concerns(tmp_path, monkeypatch):
|
||||||
|
"""Deleting a service cascades to both qbittorrent samples and media items.
|
||||||
|
|
||||||
|
Proves end-to-end cascade across both harness-managed concerns, and that
|
||||||
|
deleting one service preserves another service's data (multi-instance).
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("BACKEND_CACHE_DIR", str(tmp_path))
|
||||||
|
|
||||||
|
from media_library_viewer_api.services.media_index_impl import MediaIndex
|
||||||
|
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
|
||||||
|
from media_library_viewer_api.services.service_data import (
|
||||||
|
get_service_data_harness,
|
||||||
|
reset_service_data_harness,
|
||||||
|
)
|
||||||
|
|
||||||
|
reset_service_data_harness()
|
||||||
|
harness = get_service_data_harness()
|
||||||
|
|
||||||
|
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||||
|
store.ensure_defaults()
|
||||||
|
|
||||||
|
# --- qBit: create service, add samples, delete, verify gone ---
|
||||||
|
qbit_a = store.upsert_service(
|
||||||
|
{"service_type": "qbittorrent", "name": "qA", "config": {"base_url": "http://a"}, "enabled": True}
|
||||||
|
)
|
||||||
|
qbit_b = store.upsert_service(
|
||||||
|
{"service_type": "qbittorrent", "name": "qB", "config": {"base_url": "http://b"}, "enabled": True}
|
||||||
|
)
|
||||||
|
sample_store = QbittorrentSampleStore(harness)
|
||||||
|
sample_store.append(qbit_a["id"], ts=1000, dl_speed=500, up_speed=100)
|
||||||
|
sample_store.append(qbit_b["id"], ts=1000, dl_speed=200, up_speed=50)
|
||||||
|
|
||||||
|
assert len(sample_store.window(qbit_a["id"])) == 1
|
||||||
|
assert len(sample_store.window(qbit_b["id"])) == 1
|
||||||
|
|
||||||
|
store.delete_service(qbit_a["id"])
|
||||||
|
|
||||||
|
assert sample_store.window(qbit_a["id"]) == []
|
||||||
|
assert len(sample_store.window(qbit_b["id"])) == 1 # B survives
|
||||||
|
|
||||||
|
# --- MediaIndex: create services, add items, delete, verify scoped ---
|
||||||
|
jelly_a = store.upsert_service(
|
||||||
|
{"service_type": "jellyfin", "name": "jA", "config": {"base_url": "http://ja"}, "enabled": True}
|
||||||
|
)
|
||||||
|
jelly_b = store.upsert_service(
|
||||||
|
{"service_type": "jellyfin", "name": "jB", "config": {"base_url": "http://jb"}, "enabled": True}
|
||||||
|
)
|
||||||
|
index = MediaIndex(harness.db_path("media_index"))
|
||||||
|
index.init_schema()
|
||||||
|
index.replace_items([{"id": "m1", "title": "A1"}], service_id=jelly_a["id"])
|
||||||
|
index.replace_items([{"id": "m2", "title": "B1"}], service_id=jelly_b["id"])
|
||||||
|
|
||||||
|
rows_a, total_a = index.query(service_id=jelly_a["id"])
|
||||||
|
rows_b, total_b = index.query(service_id=jelly_b["id"])
|
||||||
|
assert total_a == 1 and total_b == 1
|
||||||
|
|
||||||
|
store.delete_service(jelly_a["id"])
|
||||||
|
|
||||||
|
rows_a_after, total_a_after = index.query(service_id=jelly_a["id"])
|
||||||
|
rows_b_after, total_b_after = index.query(service_id=jelly_b["id"])
|
||||||
|
assert total_a_after == 0 # deleted
|
||||||
|
assert total_b_after == 1 # survives
|
||||||
|
|
||||||
|
reset_service_data_harness()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Service task run history
|
# Service task run history
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user