Files
Video-Player-Controller/tests/test_app.py

495 lines
18 KiB
Python

import importlib
import os
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from unittest import mock
from controller.config import ControllerConfig
from controller.models import StoreError
from controller.mqtt_adapter import MqttAdapter
from controller.service import ControllerService, ControllerUnavailableError
class FakeControllerService:
def __init__(
self,
*,
enqueue_result=None,
enqueue_error=None,
health=None,
export_jobs=None,
):
self.enqueue_result = enqueue_result or {
"status": "queued",
"slot": 3,
"reused_slot": False,
}
self.enqueue_error = enqueue_error
self.health_result = health or {
"status": "ok",
"started": True,
"coordinator_alive": True,
"mqtt_connected": True,
"router_state": "video",
"degraded_reasons": [],
}
self.export_jobs = list(export_jobs or [])
self.enqueue_calls = []
self.export_calls = []
def enqueue_download(self, url, submitter, note):
self.enqueue_calls.append((url, submitter, note))
if self.enqueue_error is not None:
raise self.enqueue_error
return dict(self.enqueue_result)
def enqueue_export(self, url, mode, submitter, note):
self.export_calls.append((url, mode, submitter, note))
if self.enqueue_error is not None:
raise self.enqueue_error
return {
"kind": "export",
"status": "queued",
"job_id": "export-job",
"mode": mode,
}
def list_export_jobs(self, *, limit=10):
return [dict(job) for job in self.export_jobs[:limit]]
def health(self):
return dict(self.health_result)
class FlaskFactoryTests(unittest.TestCase):
def setUp(self):
self.config = ControllerConfig()
@staticmethod
def _factory():
module = importlib.import_module("app")
return module.create_app
def _app(self, service):
return self._factory()(self.config, service, testing=True)
def _post(
self,
service,
*,
url="https://www.youtube.com/playlist?list=PL1234567890",
**fields,
):
app = self._app(service)
return app.test_client().post(
"/submit",
data={"url": url, **fields},
follow_redirects=True,
)
def test_get_index_preserves_submission_form(self):
response = self._app(FakeControllerService()).test_client().get("/")
self.assertEqual(response.status_code, 200)
self.assertIn(b"Submit YouTube Media", response.data)
self.assertIn(b'action="/submit"', response.data)
self.assertIn(b'name="url"', response.data)
self.assertIn(b'name="submitter"', response.data)
self.assertIn(b'name="note"', response.data)
self.assertIn(b'name="download_mode"', response.data)
self.assertIn(b'value="player_slot" checked', response.data)
self.assertIn(b"MP4 Export", response.data)
self.assertIn(b"M4A Audio", response.data)
self.assertIn(b"MP3 Audio", response.data)
def test_older_clients_omitting_mode_still_use_player_slot(self):
service = FakeControllerService()
response = self._post(service, submitter=" Alice ")
self.assertEqual(response.status_code, 200)
self.assertEqual(
service.enqueue_calls,
[("https://www.youtube.com/playlist?list=PL1234567890", "Alice", None)],
)
self.assertEqual(service.export_calls, [])
def test_each_utility_mode_uses_export_path_without_player_slot(self):
for mode, label in (
("mp4", b"MP4 Export queued"),
("m4a", b"M4A Audio queued"),
("mp3", b"MP3 Audio queued"),
):
with self.subTest(mode=mode):
service = FakeControllerService()
response = self._post(
service,
download_mode=mode,
submitter="Operator",
note="Utility",
)
self.assertEqual(response.status_code, 200)
self.assertIn(label, response.data)
self.assertEqual(service.enqueue_calls, [])
self.assertEqual(
service.export_calls,
[
(
"https://www.youtube.com/playlist?list=PL1234567890",
mode,
"Operator",
"Utility",
)
],
)
def test_invalid_download_mode_is_rejected_server_side(self):
service = FakeControllerService()
response = self._post(service, download_mode="../../slot1")
self.assertEqual(response.status_code, 200)
self.assertIn(b"Invalid download type", response.data)
self.assertEqual(service.enqueue_calls, [])
self.assertEqual(service.export_calls, [])
def test_recent_export_status_shows_results_and_failures(self):
service = FakeControllerService(
export_jobs=[
{
"job_id": "one",
"mode": "mp4",
"status": "completed",
"output_paths": ["exports/video/one/Service.mp4"],
"error": None,
},
{
"job_id": "two",
"mode": "mp3",
"status": "failed",
"output_paths": [],
"error": "ffmpeg failed",
},
]
)
response = self._app(service).test_client().get("/")
self.assertEqual(response.status_code, 200)
self.assertIn(b"Recent utility exports", response.data)
self.assertIn(b"exports/video/one/Service.mp4", response.data)
self.assertIn(b"ffmpeg failed", response.data)
def test_submit_rejects_non_youtube_and_malformed_urls(self):
invalid_urls = (
"",
"http",
"https:///missing-host",
"httpfoo://youtube.com/video",
"ftp://youtube.com/video",
"javascript:alert(1)",
"/relative/path",
# Arbitrary external hosts, including lookalikes and substring
# tricks, must be rejected by exact normalized hostname match.
"https://example.com/video",
"https://notyoutube.com/video",
"https://youtube.com.evil.com/video",
"https://evil.com/?u=youtube.com",
"https://xyoutube.com/watch?v=abc",
"https://youtube.com.attacker.net/watch?v=abc",
# Embedded userinfo must be rejected even when the host matches.
"https://user:[email protected]/watch?v=abc",
"https://[email protected]/watch?v=abc",
"https://[email protected]/watch?v=abc",
# Oversized URL.
"https://www.youtube.com/watch?v=" + ("a" * 3000),
)
for url in invalid_urls:
with self.subTest(url=url):
service = FakeControllerService()
response = self._post(service, url=url)
self.assertEqual(response.status_code, 200)
self.assertIn(b"Invalid URL", response.data)
self.assertEqual(service.enqueue_calls, [])
def test_submit_accepts_only_explicit_official_youtube_hosts(self):
valid_urls = (
"https://youtube.com/watch?v=abc",
"https://www.youtube.com/watch?v=abc",
"http://www.youtube.com/watch?v=abc",
"https://m.youtube.com/watch?v=abc",
"https://music.youtube.com/watch?v=abc",
"https://youtu.be/abc",
"https://YouTube.com/watch?v=abc",
"https://WWW.YOUTUBE.COM/watch?v=abc",
)
for url in valid_urls:
with self.subTest(url=url):
service = FakeControllerService()
response = self._post(service, url=url)
self.assertEqual(response.status_code, 200)
self.assertEqual(service.enqueue_calls, [(url, None, None)])
def test_queued_submission_preserves_message_and_trims_optional_fields(self):
service = FakeControllerService(
enqueue_result={"status": "queued", "slot": 3, "reused_slot": False}
)
response = self._post(
service,
url="https://www.youtube.com/playlist?list=PL1234567890",
submitter=" Alice ",
note=" Before service ",
)
self.assertEqual(response.status_code, 200)
self.assertIn(b"Playlist queued in slot 3", response.data)
self.assertIn(b"start downloading shortly", response.data)
self.assertEqual(
service.enqueue_calls,
[
(
"https://www.youtube.com/playlist?list=PL1234567890",
"Alice",
"Before service",
)
],
)
def test_submit_rejects_oversized_submitter_and_note(self):
module = importlib.import_module("app")
service = FakeControllerService()
response = self._post(
service,
submitter="x" * (module.MAX_SUBMITTER_LENGTH + 1),
)
self.assertEqual(response.status_code, 200)
self.assertIn(b"must be", response.data)
self.assertEqual(service.enqueue_calls, [])
service = FakeControllerService()
response = self._post(
service,
note="x" * (module.MAX_NOTE_LENGTH + 1),
)
self.assertEqual(response.status_code, 200)
self.assertIn(b"must be", response.data)
self.assertEqual(service.enqueue_calls, [])
def test_submit_rejects_oversized_request_body(self):
service = FakeControllerService()
app = self._app(service)
response = app.test_client().post(
"/submit",
data={
"url": "https://www.youtube.com/watch?v=abc",
"note": "x" * 64_000,
},
follow_redirects=True,
)
self.assertEqual(response.status_code, 200)
self.assertIn(b"too large", response.data.lower())
self.assertEqual(service.enqueue_calls, [])
def test_reused_submission_preserves_existing_slot_message(self):
service = FakeControllerService(
enqueue_result={"status": "queued", "slot": 7, "reused_slot": True}
)
response = self._post(service)
self.assertEqual(response.status_code, 200)
self.assertIn(b"Playlist queued in slot 7", response.data)
self.assertIn(b"Existing slot", response.data)
self.assertIn(b"only new videos will be added", response.data)
def test_queue_full_has_a_useful_capacity_message(self):
service = FakeControllerService(
enqueue_result={
"status": "queue_full",
"slot": None,
"reused_slot": False,
"oldest_expires_in_seconds": 7200,
}
)
response = self._post(service)
self.assertEqual(response.status_code, 200)
self.assertIn(b"Queue is full", response.data)
self.assertIn(b"about 2 hour(s)", response.data)
self.assertNotIn(b"slot None", response.data)
def test_shutting_down_has_a_useful_retry_message(self):
service = FakeControllerService(
enqueue_result={
"status": "shutting_down",
"slot": None,
"reused_slot": False,
}
)
response = self._post(service)
self.assertEqual(response.status_code, 200)
self.assertIn(b"shutting down", response.data.lower())
self.assertIn(b"try again", response.data.lower())
self.assertNotIn(b"slot None", response.data)
def test_worker_error_has_a_useful_failure_message(self):
service = FakeControllerService(
enqueue_result={
"status": "worker_error",
"slot": 4,
"reused_slot": False,
}
)
response = self._post(service)
self.assertEqual(response.status_code, 200)
self.assertIn(b"download worker", response.data.lower())
self.assertTrue(
b"could not" in response.data.lower()
or b"failed" in response.data.lower()
)
self.assertNotIn(b"Playlist queued", response.data)
def test_controller_unavailable_is_handled_without_a_500(self):
service = FakeControllerService(
enqueue_error=ControllerUnavailableError(
"Controller coordinator did not respond"
)
)
response = self._post(service)
self.assertEqual(response.status_code, 200)
self.assertNotIn(b"Internal Server Error", response.data)
self.assertIn(b"Controller", response.data)
self.assertIn(b"unavailable", response.data.lower())
def test_store_error_is_handled_without_a_500(self):
service = FakeControllerService(
enqueue_error=StoreError("Controller database is unavailable")
)
response = self._post(service)
self.assertEqual(response.status_code, 200)
self.assertNotIn(b"Internal Server Error", response.data)
self.assertIn(b"Controller", response.data)
self.assertIn(b"unavailable", response.data.lower())
def test_health_is_healthy_when_ok_or_degraded(self):
cases = (
{
"status": "ok",
"started": True,
"coordinator_alive": True,
"mqtt_connected": True,
"degraded_reasons": [],
},
{
"status": "degraded",
"started": True,
"coordinator_alive": True,
"mqtt_connected": False,
"degraded_reasons": ["mqtt_disconnected"],
},
)
for health in cases:
with self.subTest(status=health["status"]):
app = self._app(FakeControllerService(health=health))
response = app.test_client().get("/healthz")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_json(), health)
def test_health_is_unhealthy_when_stopped_or_coordinator_is_dead(self):
cases = (
{
"status": "stopped",
"started": False,
"coordinator_alive": False,
"mqtt_connected": False,
"degraded_reasons": [],
},
{
"status": "degraded",
"started": True,
"coordinator_alive": False,
"mqtt_connected": False,
"degraded_reasons": ["coordinator_dead"],
},
)
for health in cases:
with self.subTest(status=health["status"]):
app = self._app(FakeControllerService(health=health))
response = app.test_client().get("/healthz")
self.assertEqual(response.status_code, 503)
self.assertEqual(response.get_json(), health)
def test_production_factory_requires_flask_secret_key(self):
with mock.patch.dict(os.environ, {}, clear=True):
with self.assertRaisesRegex(RuntimeError, "FLASK_SECRET_KEY"):
self._factory()(self.config, FakeControllerService())
def test_testing_factory_can_use_a_test_only_secret(self):
with mock.patch.dict(os.environ, {}, clear=True):
app = self._factory()(
self.config,
FakeControllerService(),
testing=True,
)
self.assertIs(app.testing, True)
self.assertTrue(app.secret_key)
self.assertNotEqual(app.secret_key, "dev-secret")
class AppImportLifecycleTests(unittest.TestCase):
def test_import_does_not_construct_or_start_controller_runtime(self):
with tempfile.TemporaryDirectory() as directory:
media = Path(directory) / "media"
data = Path(directory) / "data"
environment = {
**os.environ,
"YTPLAYER_MEDIA_BASE": str(media),
"YTPLAYER_DATA_BASE": str(data),
}
environment.pop("FLASK_SECRET_KEY", None)
before_threads = {thread.ident for thread in threading.enumerate()}
sys.modules.pop("app", None)
with mock.patch.dict(os.environ, environment, clear=True):
with mock.patch.object(
ControllerService,
"__init__",
side_effect=AssertionError("import constructed ControllerService"),
), mock.patch.object(
ControllerService,
"start",
side_effect=AssertionError("import started ControllerService"),
), mock.patch.object(
MqttAdapter,
"start",
side_effect=AssertionError("import started MQTT"),
):
imported = importlib.import_module("app")
self.assertTrue(callable(imported.create_app))
self.assertFalse(media.exists())
self.assertFalse(data.exists())
self.assertEqual(
{thread.ident for thread in threading.enumerate()},
before_threads,
)
sys.modules.pop("app", None)
if __name__ == "__main__":
unittest.main()