"""Tests for ServiceDataHarness lifecycle and QbittorrentSampleStore operations.""" from __future__ import annotations import pytest from media_library_viewer_api.services.qbittorrent_store import ( MAX_SAMPLES, QBITTORRENT_CONCERN, QbittorrentSampleStore, ) from media_library_viewer_api.services.service_data import ( ServiceDataHarness, StorageConcern, ) # A throwaway concern used to test harness lifecycle in isolation. _TEST_CONCERN = StorageConcern( concern_key="test", db_filename="test.db", migrations=[ """ CREATE TABLE IF NOT EXISTS test_items ( id INTEGER PRIMARY KEY, service_id TEXT NOT NULL, value TEXT ); CREATE INDEX IF NOT EXISTS idx_test_service ON test_items(service_id); """ ], tables=["test_items"], ) class TestServiceDataHarnessMigrations: def test_run_migrations_creates_tables(self, tmp_path): harness = ServiceDataHarness(tmp_path) harness.register(_TEST_CONCERN) harness.run_migrations() with harness.connect("test") as conn: tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()} assert "test_items" in tables def test_migrations_are_idempotent(self, tmp_path): """Re-running migrations on an already-migrated DB must not crash.""" harness = ServiceDataHarness(tmp_path) harness.register(_TEST_CONCERN) harness.run_migrations() harness.run_migrations() # should not raise def test_alter_table_idempotency(self, tmp_path): """ALTER TABLE ADD COLUMN must be silently skipped on re-run.""" concern = StorageConcern( concern_key="alter_test", db_filename="alter.db", migrations=[ "CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY)", "ALTER TABLE items ADD COLUMN extra TEXT DEFAULT ''", ], tables=["items"], ) harness = ServiceDataHarness(tmp_path) harness.register(concern) harness.run_migrations() harness.run_migrations() # second run: "duplicate column name" caught with harness.connect("alter_test") as conn: cols = {row[1] for row in conn.execute("PRAGMA table_info(items)").fetchall()} assert "extra" in cols class TestServiceDataHarnessCascadeDelete: def test_cascade_delete_removes_only_matching_service(self, tmp_path): harness = ServiceDataHarness(tmp_path) harness.register(_TEST_CONCERN) harness.run_migrations() with harness.connect("test") as conn: conn.execute("INSERT INTO test_items (id, service_id, value) VALUES (1, 'svc-a', 'a1')") conn.execute("INSERT INTO test_items (id, service_id, value) VALUES (2, 'svc-a', 'a2')") conn.execute("INSERT INTO test_items (id, service_id, value) VALUES (3, 'svc-b', 'b1')") conn.commit() harness.cascade_delete("svc-a") with harness.connect("test") as conn: remaining = conn.execute("SELECT service_id, value FROM test_items ORDER BY id").fetchall() assert len(remaining) == 1 assert remaining[0][0] == "svc-b" def test_cascade_delete_skips_missing_concern_db(self, tmp_path): """cascade_delete on a concern whose DB file doesn't exist should not crash.""" harness = ServiceDataHarness(tmp_path) harness.register(_TEST_CONCERN) # No run_migrations → DB file doesn't exist harness.cascade_delete("svc-x") # should not raise class TestQbittorrentSampleStore: @pytest.fixture() def store(self, tmp_path): harness = ServiceDataHarness(tmp_path) harness.register(QBITTORRENT_CONCERN) harness.run_migrations() return QbittorrentSampleStore(harness=harness) def test_append_and_window(self, store): store.append("svc-1", ts=100, dl_speed=500, up_speed=50) store.append("svc-1", ts=200, dl_speed=600, up_speed=60) store.append("svc-1", ts=300, dl_speed=700, up_speed=70) samples = store.window("svc-1") assert len(samples) == 3 assert samples[0]["ts"] == 100 assert samples[2]["ts"] == 300 assert samples[1]["dl_speed"] == 600 def test_window_with_since_ts(self, store): store.append("svc-1", ts=100, dl_speed=500, up_speed=50) store.append("svc-1", ts=200, dl_speed=600, up_speed=60) store.append("svc-1", ts=300, dl_speed=700, up_speed=70) samples = store.window("svc-1", since_ts=200) assert len(samples) == 2 assert samples[0]["ts"] == 200 def test_prune_enforces_max_samples(self, store): for i in range(MAX_SAMPLES + 10): store.append("svc-1", ts=i, dl_speed=i, up_speed=i) samples = store.window("svc-1") assert len(samples) == MAX_SAMPLES # The oldest 10 should have been pruned assert samples[0]["ts"] == 10 assert samples[-1]["ts"] == MAX_SAMPLES + 9 def test_two_services_do_not_cross_contaminate(self, store): store.append("svc-a", ts=100, dl_speed=500, up_speed=50) store.append("svc-b", ts=200, dl_speed=600, up_speed=60) a_samples = store.window("svc-a") b_samples = store.window("svc-b") assert len(a_samples) == 1 assert a_samples[0]["dl_speed"] == 500 assert len(b_samples) == 1 assert b_samples[0]["dl_speed"] == 600