Archived
Add watchlist backup import export
This commit is contained in:
+121
@@ -72,6 +72,12 @@ class DummyHandler:
|
||||
self.json_payload = payload
|
||||
self.json_status = status
|
||||
|
||||
def json_attachment(self, payload, filename, status=HTTPStatus.OK):
|
||||
return APP.Handler.json_attachment(self, payload, filename, status=status)
|
||||
|
||||
def write_response_bytes(self, payload, status, headers):
|
||||
return APP.Handler.write_response_bytes(self, payload, status, headers)
|
||||
|
||||
def error(self, status, message):
|
||||
self.error_status = status
|
||||
self.error_message = message
|
||||
@@ -2385,6 +2391,92 @@ class WatchlistFilesystemReconcileTests(unittest.TestCase):
|
||||
self.assertTrue(item["downloaded"])
|
||||
|
||||
|
||||
class WatchlistBackupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
with APP.WATCHLIST._connect() as conn:
|
||||
conn.execute("DELETE FROM watchlist")
|
||||
conn.execute("DELETE FROM watchlist_removals")
|
||||
|
||||
def seed_watchlist_item(self, **overrides):
|
||||
now = APP.now_iso()
|
||||
item = {
|
||||
"show_id": "show-backup-1",
|
||||
"title": "Backup Show",
|
||||
"category": "finished",
|
||||
"downloaded": True,
|
||||
"status": "updated",
|
||||
"status_message": "Ready.",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"last_checked": now,
|
||||
"sub_count": 2,
|
||||
"dub_count": 2,
|
||||
"expected_count": 2,
|
||||
"airing_status": "Finished",
|
||||
"sub_latest_episode": "2",
|
||||
"dub_latest_episode": "2",
|
||||
"animeschedule_route": "backup-route",
|
||||
"animeschedule_title": "Backup Schedule",
|
||||
"anidb_aid": "123",
|
||||
"anidb_title": "Backup AniDB",
|
||||
"media_type": "tv",
|
||||
"auto_download_mode": "dub",
|
||||
"auto_download_quality": "best",
|
||||
"auto_download_name": "Backup Show",
|
||||
"auto_download_source_name": "Backup Search",
|
||||
"auto_download_series": "1",
|
||||
"auto_download_offset": None,
|
||||
"downloaded_sub_episodes_json": APP.encode_episode_values(["1"]),
|
||||
"downloaded_dub_episodes_json": APP.encode_episode_values(["1", "2"]),
|
||||
"thumbnail_path": "show-backup-1.jpg",
|
||||
"thumbnail_checked_at": now,
|
||||
}
|
||||
item.update(overrides)
|
||||
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
|
||||
APP.WATCHLIST._upsert_conn(conn, item)
|
||||
|
||||
def test_export_watchlist_backup_includes_raw_watchlist_and_removal_rows(self):
|
||||
self.seed_watchlist_item()
|
||||
with APP.WATCHLIST._connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO watchlist_removals (show_id, title, removed_at) VALUES (?, ?, ?)",
|
||||
("removed-show", "Removed Show", APP.now_iso()),
|
||||
)
|
||||
|
||||
backup = APP.export_watchlist_backup()
|
||||
|
||||
self.assertEqual(backup["format"], "ani-cli-web-watchlist-backup")
|
||||
self.assertEqual(backup["format_version"], 1)
|
||||
self.assertEqual(backup["tables"]["watchlist"]["rows"][0]["show_id"], "show-backup-1")
|
||||
self.assertEqual(backup["tables"]["watchlist"]["rows"][0]["downloaded_dub_episodes_json"], APP.encode_episode_values(["1", "2"]))
|
||||
self.assertEqual(backup["tables"]["watchlist_removals"]["rows"][0]["show_id"], "removed-show")
|
||||
|
||||
def test_import_watchlist_backup_replaces_existing_watchlist_tables(self):
|
||||
self.seed_watchlist_item(show_id="old-show", title="Old Show")
|
||||
backup = APP.export_watchlist_backup()
|
||||
backup["tables"]["watchlist"]["rows"][0]["show_id"] = "new-show"
|
||||
backup["tables"]["watchlist"]["rows"][0]["title"] = "New Show"
|
||||
backup["tables"]["watchlist_removals"]["rows"] = [
|
||||
{"show_id": "removed-new", "title": "Removed New", "removed_at": APP.now_iso()}
|
||||
]
|
||||
|
||||
result = APP.import_watchlist_backup(backup)
|
||||
|
||||
self.assertEqual(result["imported"], 1)
|
||||
with self.assertRaises(KeyError):
|
||||
APP.WATCHLIST.get("old-show")
|
||||
restored = APP.WATCHLIST.get("new-show")
|
||||
self.assertEqual(restored["title"], "New Show")
|
||||
self.assertEqual(restored["downloaded_dub_episodes"], ["1", "2"])
|
||||
with APP.WATCHLIST._connect() as conn:
|
||||
removal = conn.execute("SELECT title FROM watchlist_removals WHERE show_id = ?", ("removed-new",)).fetchone()
|
||||
self.assertEqual(removal["title"], "Removed New")
|
||||
|
||||
def test_import_watchlist_backup_rejects_unrecognized_format(self):
|
||||
with self.assertRaises(ValueError):
|
||||
APP.import_watchlist_backup({"format": "not-this-app", "format_version": 1, "tables": {}})
|
||||
|
||||
|
||||
class WatchlistQueuedRefreshRecoveryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
with APP.WATCHLIST._connect() as conn:
|
||||
@@ -3762,6 +3854,35 @@ class HandlerRouteTests(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
|
||||
def test_watchlist_export_route_returns_json_attachment(self):
|
||||
handler = DummyHandler("/api/config/watchlist/export")
|
||||
payload = {"format": "ani-cli-web-watchlist-backup", "tables": {"watchlist": {"rows": []}}}
|
||||
handler.handler_context = mock.Mock(
|
||||
ensure_runtime=mock.Mock(),
|
||||
runtime_state=mock.Mock(return_value={}),
|
||||
export_watchlist_backup=mock.Mock(return_value=payload),
|
||||
)
|
||||
|
||||
APP.Handler.do_GET(handler)
|
||||
|
||||
self.assertEqual(handler.response_status, HTTPStatus.OK)
|
||||
headers = dict(handler.response_headers)
|
||||
self.assertEqual(headers.get("Content-Type"), "application/json")
|
||||
self.assertIn("attachment", headers.get("Content-Disposition", ""))
|
||||
self.assertEqual(json.loads(handler.wfile.getvalue().decode("utf-8")), payload)
|
||||
|
||||
def test_watchlist_import_route_returns_import_summary(self):
|
||||
body = b'{"format":"ani-cli-web-watchlist-backup","format_version":1,"tables":{"watchlist":{"rows":[]}}}'
|
||||
handler = DummyHandler("/api/config/watchlist/import", body=body, content_length=len(body))
|
||||
payload = {"message": "Imported 0 watchlist entries from backup.", "imported": 0, "removals": 0}
|
||||
handler.handler_context = mock.Mock(import_watchlist_backup=mock.Mock(return_value=payload))
|
||||
|
||||
APP.Handler.do_POST(handler)
|
||||
|
||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
||||
self.assertEqual(handler.json_payload, payload)
|
||||
handler.handler_context.import_watchlist_backup.assert_called_once()
|
||||
|
||||
def test_generic_handler_exception_skips_traceback_when_debug_disabled(self):
|
||||
handler = DummyHandler("/api/config")
|
||||
with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object(
|
||||
|
||||
Reference in New Issue
Block a user