refactor: wire controller v2 into web runtime
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
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):
|
||||
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.enqueue_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 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://example.com/playlist", **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 Playlist", 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)
|
||||
|
||||
def test_submit_accepts_only_http_or_https_urls_with_a_network_location(self):
|
||||
invalid_urls = (
|
||||
"",
|
||||
"http",
|
||||
"https:///missing-host",
|
||||
"httpfoo://example.com/video",
|
||||
"ftp://example.com/video",
|
||||
"javascript:alert(1)",
|
||||
"/relative/path",
|
||||
)
|
||||
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, [])
|
||||
|
||||
for url in ("http://example.com/video", "https://example.com/video"):
|
||||
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://example.com/playlist",
|
||||
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://example.com/playlist", "Alice", "Before service")],
|
||||
)
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user