diff --git a/.dockerignore b/.dockerignore index e7de836..03992f6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,10 @@ +.git +.github .venv/ +.venv venv/ __pycache__/ +__pycache__ *.py[cod] .pytest_cache/ *.log @@ -10,3 +14,4 @@ logs/ .LSOverride Thumbs.db Desktop.ini +.idea diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..eb94305 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,22 @@ +name: Tests + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + docker-tests: + name: Dockerized Python test suite + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Build test image + run: docker build --target test -t propresenter-notes:test . + + - name: Run tests in Docker + run: docker run --rm propresenter-notes:test diff --git a/Dockerfile b/Dockerfile index 97186e0..e738f0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-slim +FROM python:3.12-slim AS base ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 @@ -8,6 +8,17 @@ WORKDIR /app COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt +FROM base AS test + +COPY server.py config.json ./ +COPY propresenter_notes/ ./propresenter_notes/ +COPY scripts/ ./scripts/ +COPY tests/ ./tests/ + +CMD ["python", "-m", "unittest", "discover", "-s", "tests", "-v"] + +FROM base AS runtime + COPY server.py config.json ./ COPY propresenter_notes/ ./propresenter_notes/ diff --git a/README.md b/README.md index fb9b48f..c02a793 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,33 @@ Then open: http://127.0.0.1:3000 ``` +## Run tests + +Run the Python unit test suite locally with: + +```bash +python -m unittest discover -s tests -v +``` + +Run the same test suite in Docker with the test build target used by CI: + +```bash +docker build --target test -t propresenter-notes:test . +docker run --rm propresenter-notes:test +``` + +The test suite includes a local mock ProPresenter API server backed by the captured OpenAPI example payloads in `tests/propresenter_openapi_examples.json`. Those integration tests exercise the real `ProPresenterClient` over HTTP for `/version`, `/v1/libraries`, and `/v1/library/{library_id}` without requiring a running ProPresenter instance. As more Swagger examples are collected, add them to that fixture and extend `tests/mock_propresenter_api.py` so Docker and CI continue validating against the documented API contract. + +To capture examples from a real ProPresenter system for review, run: + +```bash +python scripts/capture_propresenter_examples.py --base-url http://127.0.0.1:1025 --output propresenter_examples_capture.json +``` + +The capture script reads `/version`, `/v1/libraries`, `/v1/library/{library_id}` using UUID/name/index forms, selected presentation details, the first slide thumbnail, and slide-status endpoints. It intentionally skips trigger endpoints because they mutate the live ProPresenter state. Review the generated file for sensitive presentation text before sharing it or copying examples into `tests/propresenter_openapi_examples.json`. + +GitHub Actions runs the Dockerized test suite on pushes to `main`, pull requests, and manual workflow dispatches. + ## Run with Docker Docker is an optional deployment path; the macOS service scripts above remain supported. Build the image from this folder with: diff --git a/scripts/capture_propresenter_examples.py b/scripts/capture_propresenter_examples.py new file mode 100755 index 0000000..115bc20 --- /dev/null +++ b/scripts/capture_propresenter_examples.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Capture ProPresenter REST API examples from a real ProPresenter instance. + +The generated JSON is intended to be checked by a human and then copied into +`tests/propresenter_openapi_examples.json` so the mock API tests can run without +requiring a live ProPresenter system. +""" +from __future__ import annotations + +import argparse +import base64 +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +DEFAULT_OUTPUT = Path("propresenter_examples_capture.json") +DEFAULT_TIMEOUT_SECONDS = 5.0 + + +def default_base_url() -> str: + explicit = os.environ.get("PROPRESENTER_BASE_URL", "").strip() + if explicit: + return explicit.rstrip("/") + + scheme = os.environ.get("PROPRESENTER_SCHEME", "http").strip() or "http" + host = os.environ.get("PROPRESENTER_HOST", "127.0.0.1").strip() or "127.0.0.1" + port = os.environ.get("PROPRESENTER_PORT", "1025").strip() or "1025" + return f"{scheme}://{host}:{port}".rstrip("/") + + +def quote_path_value(value: Any) -> str: + return urllib.parse.quote(str(value), safe="") + + +def decode_body(raw_body: bytes, content_type: str) -> dict[str, Any]: + result: dict[str, Any] = { + "content_type": content_type, + "body_length": len(raw_body), + } + content_type_lower = content_type.lower() + + if not raw_body: + result["body"] = None + return result + + if content_type_lower.startswith("image/") or "octet-stream" in content_type_lower: + result["body_base64"] = base64.b64encode(raw_body).decode("ascii") + return result + + text = raw_body.decode("utf-8", errors="replace") + try: + result["body"] = json.loads(text) + except json.JSONDecodeError: + result["body"] = text + return result + + +def fetch(base_url: str, api_path: str, timeout_seconds: float) -> dict[str, Any]: + url = f"{base_url.rstrip('/')}{api_path}" + request = urllib.request.Request(url, headers={"Accept": "application/json, image/*, text/plain, */*"}) + + try: + with urllib.request.urlopen(request, timeout=timeout_seconds) as response: + raw_body = response.read() + content_type = response.headers.get("content-type", "") + payload = decode_body(raw_body, content_type) + payload.update({"status": response.status, "path": api_path, "ok": 200 <= response.status < 300}) + return payload + except urllib.error.HTTPError as exc: + raw_body = exc.read() + content_type = exc.headers.get("content-type", "") if exc.headers else "" + payload = decode_body(raw_body, content_type) + payload.update({"status": exc.code, "path": api_path, "ok": False}) + return payload + except Exception as exc: + return {"status": None, "path": api_path, "ok": False, "error": str(exc)} + + +def body_from(result: dict[str, Any]) -> Any: + return result.get("body") + + +def library_identifiers(library: Any) -> list[str]: + if not isinstance(library, dict): + return [str(library)] if library not in (None, "") else [] + + identifiers = [] + for key in ("uuid", "name", "index"): + value = library.get(key) + if value not in (None, ""): + identifiers.append(str(value)) + return identifiers + + +def first_successful_library(captured_libraries: dict[str, dict[str, Any]]) -> dict[str, Any] | None: + for result in captured_libraries.values(): + if result.get("ok"): + return result + return None + + +def presentation_identifiers(library_result: dict[str, Any] | None) -> list[str]: + if not library_result: + return [] + + library_body = body_from(library_result) + items = library_body.get("items") if isinstance(library_body, dict) else [] + identifiers = [] + for item in items if isinstance(items, list) else []: + if not isinstance(item, dict): + continue + for key in ("uuid", "name", "index"): + value = item.get(key) + if value not in (None, ""): + identifiers.append(str(value)) + break + return identifiers + + +def capture_examples(base_url: str, timeout_seconds: float) -> dict[str, Any]: + output: dict[str, Any] = { + "metadata": { + "generated_at": datetime.now(timezone.utc).isoformat(), + "base_url": base_url.rstrip("/"), + "notes": [ + "Generated by scripts/capture_propresenter_examples.py.", + "Review before committing; endpoint bodies may include local presentation names or slide text.", + "Trigger endpoints are intentionally not requested because they mutate the live ProPresenter state.", + ], + }, + "skipped": { + "trigger_next": { + "path": "/v1/trigger/next", + "reason": "Skipped because this advances the live ProPresenter show.", + }, + "trigger_previous": { + "path": "/v1/trigger/previous", + "reason": "Skipped because this changes the live ProPresenter show.", + }, + "trigger_slide": { + "path": "/v1/presentation/{presentation_id}/{index}/trigger", + "reason": "Skipped because this changes the live ProPresenter show.", + }, + }, + } + + output["version"] = fetch(base_url, "/version", timeout_seconds) + output["libraries"] = fetch(base_url, "/v1/libraries", timeout_seconds) + + libraries_body = body_from(output["libraries"]) + captured_libraries: dict[str, dict[str, Any]] = {} + for library in libraries_body if isinstance(libraries_body, list) else []: + for identifier in library_identifiers(library): + path = f"/v1/library/{quote_path_value(identifier)}" + captured_libraries[identifier] = fetch(base_url, path, timeout_seconds) + + output["libraries_by_id"] = captured_libraries + + first_library = first_successful_library(captured_libraries) + if first_library: + output["library"] = { + "status": first_library.get("status"), + "path": first_library.get("path"), + "content_type": first_library.get("content_type"), + "body": first_library.get("body"), + } + + captured_presentations: dict[str, dict[str, Any]] = {} + for presentation_id in presentation_identifiers(first_library)[:3]: + path = f"/v1/presentation/{quote_path_value(presentation_id)}" + captured_presentations[presentation_id] = fetch(base_url, path, timeout_seconds) + output["presentations_by_id"] = captured_presentations + + first_presentation_id = next(iter(captured_presentations), "") + if first_presentation_id: + thumbnail_path = ( + f"/v1/presentation/{quote_path_value(first_presentation_id)}" + "/thumbnail/0?quality=400&thumbnail_type=jpeg" + ) + output["thumbnail"] = fetch(base_url, thumbnail_path, timeout_seconds) + + output["status_slide"] = fetch(base_url, "/v1/status/slide", timeout_seconds) + output["presentation_active"] = fetch(base_url, "/v1/presentation/active", timeout_seconds) + return output + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Capture ProPresenter API examples for the mock API test fixture." + ) + parser.add_argument( + "--base-url", + default=default_base_url(), + help="Base URL for ProPresenter, for example http://127.0.0.1:1025. " + "Defaults to PROPRESENTER_BASE_URL or PROPRESENTER_SCHEME/HOST/PORT.", + ) + parser.add_argument( + "--output", + type=Path, + default=DEFAULT_OUTPUT, + help=f"Output JSON path. Defaults to {DEFAULT_OUTPUT}.", + ) + parser.add_argument( + "--timeout", + type=float, + default=DEFAULT_TIMEOUT_SECONDS, + help=f"Per-request timeout in seconds. Defaults to {DEFAULT_TIMEOUT_SECONDS}.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + examples = capture_examples(args.base_url.rstrip("/"), args.timeout) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(examples, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(f"Wrote ProPresenter examples to {args.output}", file=sys.stderr) + print("Please review the file for sensitive presentation text before sharing it.", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_propresenter_api.py b/tests/mock_propresenter_api.py new file mode 100644 index 0000000..0eb9a18 --- /dev/null +++ b/tests/mock_propresenter_api.py @@ -0,0 +1,88 @@ +"""Spec-example backed mock ProPresenter API for integration tests.""" +from __future__ import annotations + +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, unquote, urlparse + +EXAMPLES_PATH = Path(__file__).with_name("propresenter_openapi_examples.json") + + +def load_examples() -> dict[str, Any]: + with EXAMPLES_PATH.open(encoding="utf-8") as examples_file: + return json.load(examples_file) + + +class MockProPresenterAPIServer: + """Runs a local HTTP API that serves captured OpenAPI example payloads.""" + + def __init__(self, examples: dict[str, Any] | None = None) -> None: + self.examples = examples or load_examples() + self.requests: list[dict[str, Any]] = [] + self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._handler_class()) + self.base_url = f"http://127.0.0.1:{self._server.server_address[1]}" + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + + def __enter__(self) -> "MockProPresenterAPIServer": + self._thread.start() + return self + + def __exit__(self, _exc_type: object, _exc: object, _tb: object) -> None: + self._server.shutdown() + self._server.server_close() + self._thread.join(timeout=5) + + def _handler_class(self) -> type[BaseHTTPRequestHandler]: + examples = self.examples + requests = self.requests + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + parsed = urlparse(self.path) + path = parsed.path + query = parse_qs(parsed.query) + requests.append({"method": "GET", "path": path, "query": query}) + + if path == "/version": + self._send_example(examples["version"]) + return + + if path == "/v1/libraries": + self._send_example(examples["libraries"]) + return + + if path.startswith("/v1/library/"): + library_id = unquote(path.removeprefix("/v1/library/")) + if self._known_library_id(library_id): + self._send_example(examples["library"]) + else: + self._send_json(404, {"error": "Library not found"}) + return + + self._send_json(404, {"error": "Not found"}) + + def log_message(self, _format: str, *_args: object) -> None: + return + + def _known_library_id(self, library_id: str) -> bool: + for library in examples["libraries"]["body"]: + identifiers = {str(library["uuid"]), str(library["name"]), str(library["index"])} + if library_id in identifiers: + return True + return False + + def _send_example(self, example: dict[str, Any]) -> None: + self._send_json(int(example["status"]), example["body"]) + + def _send_json(self, status: int, body: Any) -> None: + payload = json.dumps(body).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + return Handler diff --git a/tests/propresenter_openapi_examples.json b/tests/propresenter_openapi_examples.json new file mode 100644 index 0000000..0b1cce8 --- /dev/null +++ b/tests/propresenter_openapi_examples.json @@ -0,0 +1,75 @@ +{ + "version": { + "status": 200, + "body": { + "api_version": "v1", + "host_description": "ProPresenter 7.8.1", + "name": "Main sanctuary Pro7 machine", + "os_version": "10.0.19043", + "platform": "win" + } + }, + "libraries": { + "status": 200, + "body": [ + { + "uuid": "30afaec9-33ff-406e-a2fa-7b7596aa56c2", + "name": "Default", + "index": 0 + }, + { + "uuid": "fbd814b9-530c-4a66-bef1-0bd5a1db44ff", + "name": "Sample", + "index": 1 + } + ] + }, + "library": { + "status": 200, + "body": { + "update_type": "all", + "items": [ + { + "uuid": "f12ddb18-c62e-43e7-a8e0-c0d3878cf329", + "name": "Announcements", + "index": 0 + }, + { + "uuid": "ee45cc69-d632-40f0-916d-68487941fe7b", + "name": "Glorious Day FB LIVE", + "index": 1 + }, + { + "uuid": "1e8cbfb0-add2-4e17-ba88-c73831bd97b5", + "name": "Same God", + "index": 2 + }, + { + "uuid": "70623736-5b63-4dd6-83e5-2c01c2897240", + "name": "Sermon Deck", + "index": 3 + }, + { + "uuid": "b8848aa1-ab9d-4532-af52-3fde3ce52319", + "name": "This Is Amazing Grace BIG TEXT-1", + "index": 4 + }, + { + "uuid": "5584afc7-f98e-4c8e-8b6b-a9f5b66f37b8", + "name": "Way Maker", + "index": 5 + }, + { + "uuid": "f87fdb41-f9d5-41ca-9473-62f407703407", + "name": "Welcome Video", + "index": 6 + }, + { + "uuid": "b22d5e2e-3622-43b4-8724-46302ca3277c", + "name": "Greater Love Memorial Day", + "index": 7 + } + ] + } + } +} diff --git a/tests/test_capture_propresenter_examples.py b/tests/test_capture_propresenter_examples.py new file mode 100644 index 0000000..6316b58 --- /dev/null +++ b/tests/test_capture_propresenter_examples.py @@ -0,0 +1,52 @@ +import importlib.util +import unittest +from pathlib import Path + +SCRIPT_PATH = Path(__file__).resolve().parent.parent / "scripts" / "capture_propresenter_examples.py" +SPEC = importlib.util.spec_from_file_location("capture_propresenter_examples", SCRIPT_PATH) +capture_script = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(capture_script) + + +class CaptureProPresenterExamplesTests(unittest.TestCase): + def test_library_identifiers_include_uuid_name_and_index(self): + identifiers = capture_script.library_identifiers( + { + "uuid": "30afaec9-33ff-406e-a2fa-7b7596aa56c2", + "name": "Default", + "index": 0, + } + ) + + self.assertEqual(["30afaec9-33ff-406e-a2fa-7b7596aa56c2", "Default", "0"], identifiers) + + def test_presentation_identifiers_choose_first_available_value_per_item(self): + identifiers = capture_script.presentation_identifiers( + { + "ok": True, + "body": { + "update_type": "all", + "items": [ + {"uuid": "deck-uuid", "name": "Deck", "index": 0}, + {"name": "Name Only", "index": 1}, + {"index": 2}, + "ignored", + ], + }, + } + ) + + self.assertEqual(["deck-uuid", "Name Only", "2"], identifiers) + + def test_decode_body_preserves_binary_payload_as_base64(self): + decoded = capture_script.decode_body(b"jpeg", "image/jpeg") + + self.assertEqual("image/jpeg", decoded["content_type"]) + self.assertEqual(4, decoded["body_length"]) + self.assertEqual("anBlZw==", decoded["body_base64"]) + self.assertNotIn("body", decoded) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..954db6c --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,102 @@ +import json +import unittest +import urllib.error +from unittest.mock import MagicMock, patch + +from propresenter_notes.client import ( + ProPresenterClient, + ProPresenterError, + clean_api_path, + is_image_response, + parse_response_body, +) + + +class ClientHelperTests(unittest.TestCase): + def test_clean_api_path_allows_supported_paths_and_query_strings(self): + self.assertEqual("/version", clean_api_path("/version")) + self.assertEqual("/v1/libraries?include=all", clean_api_path("/v1/libraries?include=all")) + + def test_clean_api_path_rejects_unsupported_paths(self): + with self.assertRaises(ValueError): + clean_api_path("/v2/libraries") + + with self.assertRaises(ValueError): + clean_api_path("https://example.com/v1/libraries") + + def test_parse_response_body_handles_json_text_empty_and_invalid_json(self): + self.assertIsNone(parse_response_body(b"", "application/json")) + self.assertEqual({"ok": True}, parse_response_body(b'{"ok": true}', "application/json")) + self.assertEqual({"ok": True}, parse_response_body(b'{"ok": true}', "text/plain")) + self.assertEqual("not json", parse_response_body(b"not json", "application/json")) + + def test_is_image_response_requires_image_content_type(self): + self.assertTrue(is_image_response("image/jpeg")) + self.assertTrue(is_image_response("IMAGE/PNG; charset=binary")) + self.assertFalse(is_image_response("application/json")) + + +class ProPresenterClientTests(unittest.TestCase): + @patch("propresenter_notes.client.urllib.request.urlopen") + def test_fetch_sends_json_body_and_parses_response(self, urlopen): + response = MagicMock() + response.status = 200 + response.read.return_value = b'{"saved": true}' + response.headers.get.return_value = "application/json" + response.__enter__.return_value = response + urlopen.return_value = response + + client = ProPresenterClient("http://localhost:1025/", 1.5) + status, data = client.fetch("/v1/test", method="POST", body={"name": "Deck"}) + + self.assertEqual(200, status) + self.assertEqual({"saved": True}, data) + request = urlopen.call_args.args[0] + self.assertEqual("http://localhost:1025/v1/test", request.full_url) + self.assertEqual("POST", request.get_method()) + self.assertEqual(json.dumps({"name": "Deck"}).encode("utf-8"), request.data) + self.assertEqual("application/json", request.headers["Content-type"]) + + @patch("propresenter_notes.client.urllib.request.urlopen") + def test_fetch_raw_wraps_http_errors_with_details(self, urlopen): + error = urllib.error.HTTPError( + url="http://localhost:1025/v1/test", + code=418, + msg="teapot", + hdrs={"content-type": "application/json"}, + fp=None, + ) + error.read = MagicMock(return_value=b'{"error": "short"}') + urlopen.side_effect = error + + client = ProPresenterClient("http://localhost:1025", 1.0) + + with self.assertRaises(ProPresenterError) as raised: + client.fetch("/v1/test") + + self.assertEqual(418, raised.exception.status_code) + self.assertEqual( + {"content_type": "application/json", "details": {"error": "short"}}, + raised.exception.details, + ) + + @patch("propresenter_notes.client.urllib.request.urlopen") + def test_fetch_image_rejects_non_image_responses(self, urlopen): + response = MagicMock() + response.status = 200 + response.read.return_value = b'{"not": "image"}' + response.headers.get.return_value = "application/json" + response.__enter__.return_value = response + urlopen.return_value = response + + client = ProPresenterClient("http://localhost:1025", 1.0) + + with self.assertRaises(ProPresenterError) as raised: + client.fetch_image("/v1/presentation/deck/thumbnail/0") + + self.assertEqual(200, raised.exception.status_code) + self.assertEqual({"content_type": "application/json"}, raised.exception.details) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..eb581ee --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,94 @@ +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from propresenter_notes.config import Settings, load_settings + + +class SettingsTests(unittest.TestCase): + def test_base_url_and_timeout_are_derived_from_settings(self): + settings = Settings( + propresenter_scheme="https", + propresenter_host="propresenter.local", + propresenter_port=2048, + poll_timeout_ms=1250, + ) + + self.assertEqual("https://propresenter.local:2048", settings.propresenter_base_url) + self.assertEqual(1.25, settings.timeout_seconds) + + def test_timeout_has_minimum_floor(self): + self.assertEqual(0.25, Settings(poll_timeout_ms=10).timeout_seconds) + + +class LoadSettingsTests(unittest.TestCase): + def test_missing_config_uses_defaults(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = load_settings(Path(tmpdir) / "missing.json") + + self.assertEqual(Settings(), settings) + + def test_invalid_integers_fall_back_to_defaults(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "config.json" + path.write_text( + json.dumps( + { + "app_port": "not-a-port", + "propresenter_port": None, + "poll_timeout_ms": "slow", + } + ), + encoding="utf-8", + ) + + settings = load_settings(path) + + self.assertEqual(Settings.app_port, settings.app_port) + self.assertEqual(Settings.propresenter_port, settings.propresenter_port) + self.assertEqual(Settings.poll_timeout_ms, settings.poll_timeout_ms) + + def test_environment_overrides_config_values(self): + with tempfile.TemporaryDirectory() as tmpdir, patch.dict( + "os.environ", + { + "APP_HOST": "0.0.0.0", + "APP_PORT": "8080", + "PROPRESENTER_SCHEME": "https", + "PROPRESENTER_HOST": "deck.local", + "PROPRESENTER_PORT": "2048", + "POLL_TIMEOUT_MS": "5000", + "UI_PIN": " 9999 ", + }, + ): + path = Path(tmpdir) / "config.json" + path.write_text( + json.dumps( + { + "app_host": "127.0.0.1", + "app_port": 3000, + "propresenter_scheme": "http", + "propresenter_host": "127.0.0.1", + "propresenter_port": 1025, + "poll_timeout_ms": 2500, + "ui_pin": "1234", + } + ), + encoding="utf-8", + ) + + settings = load_settings(path) + + self.assertEqual("0.0.0.0", settings.app_host) + self.assertEqual(8080, settings.app_port) + self.assertEqual("https", settings.propresenter_scheme) + self.assertEqual("deck.local", settings.propresenter_host) + self.assertEqual(2048, settings.propresenter_port) + self.assertEqual(5000, settings.poll_timeout_ms) + self.assertEqual("9999", settings.ui_pin) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mock_propresenter_api.py b/tests/test_mock_propresenter_api.py new file mode 100644 index 0000000..dbb2991 --- /dev/null +++ b/tests/test_mock_propresenter_api.py @@ -0,0 +1,95 @@ +import unittest + +from propresenter_notes import create_app +from propresenter_notes.client import ProPresenterClient, ProPresenterError +from propresenter_notes.config import Settings + +from tests.mock_propresenter_api import MockProPresenterAPIServer + + +class MockProPresenterAPIIntegrationTests(unittest.TestCase): + def make_app_client(self, base_url): + app = create_app(Settings(propresenter_host="127.0.0.1", ui_pin="")) + app.config.update(TESTING=True) + app.extensions["propresenter_client"] = ProPresenterClient(base_url, 2) + return app.test_client() + + def test_health_uses_spec_example_version_payload_from_mock_api(self): + with MockProPresenterAPIServer() as mock_api: + response = self.make_app_client(mock_api.base_url).get("/api/health") + + self.assertEqual(200, response.status_code) + self.assertEqual( + { + "ok": True, + "path": "/version", + "data": { + "api_version": "v1", + "host_description": "ProPresenter 7.8.1", + "name": "Main sanctuary Pro7 machine", + "os_version": "10.0.19043", + "platform": "win", + }, + }, + response.get_json(), + ) + + def test_libraries_and_presentations_are_discovered_through_mock_api(self): + with MockProPresenterAPIServer() as mock_api: + client = self.make_app_client(mock_api.base_url) + libraries_response = client.get("/api/libraries") + presentations_response = client.get("/api/presentations") + + request_paths = [request["path"] for request in mock_api.requests] + + self.assertEqual(200, libraries_response.status_code) + self.assertEqual( + [ + { + "uuid": "30afaec9-33ff-406e-a2fa-7b7596aa56c2", + "name": "Default", + "index": 0, + }, + { + "uuid": "fbd814b9-530c-4a66-bef1-0bd5a1db44ff", + "name": "Sample", + "index": 1, + }, + ], + libraries_response.get_json(), + ) + presentations = presentations_response.get_json() + self.assertEqual(16, len(presentations)) + self.assertEqual("Announcements", presentations[0]["presentation"]["name"]) + self.assertEqual("Greater Love Memorial Day", presentations[-1]["presentation"]["name"]) + self.assertEqual( + [ + "/v1/libraries", + "/v1/libraries", + "/v1/library/30afaec9-33ff-406e-a2fa-7b7596aa56c2", + "/v1/library/fbd814b9-530c-4a66-bef1-0bd5a1db44ff", + ], + request_paths, + ) + + def test_library_route_accepts_name_uuid_or_index_and_returns_404_for_unknown_ids(self): + with MockProPresenterAPIServer() as mock_api: + client = ProPresenterClient(mock_api.base_url, 2) + for library_id in ("30afaec9-33ff-406e-a2fa-7b7596aa56c2", "Default", "0"): + status, payload = client.fetch(f"/v1/library/{library_id}") + self.assertEqual(200, status) + self.assertEqual("all", payload["update_type"]) + self.assertEqual("Sermon Deck", payload["items"][3]["name"]) + + with self.assertRaises(ProPresenterError) as raised: + client.fetch("/v1/library/missing") + + self.assertEqual(404, raised.exception.status_code) + self.assertEqual( + {"content_type": "application/json", "details": {"error": "Library not found"}}, + raised.exception.details, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_routes.py b/tests/test_routes.py index 3f6beba..73379a0 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -1,19 +1,32 @@ import unittest from propresenter_notes import create_app +from propresenter_notes.client import ProPresenterError from propresenter_notes.config import Settings class FakeProPresenterClient: def __init__(self): self.paths = [] + self.responses = {} + self.image_responses = {} def fetch(self, api_path, method="GET", body=None): self.paths.append((api_path, method, body)) - return 200, None + response = self.responses.get(api_path, (200, None)) + if isinstance(response, Exception): + raise response + return response + def fetch_image(self, api_path, method="GET"): + self.paths.append((api_path, method, None)) + response = self.image_responses.get(api_path, (200, b"jpeg-bytes", "image/jpeg")) + if isinstance(response, Exception): + raise response + return response -class TriggerRouteTests(unittest.TestCase): + +class RouteTests(unittest.TestCase): def make_client(self): app = create_app(Settings(ui_pin="")) app.config.update(TESTING=True) @@ -21,6 +34,94 @@ def make_client(self): app.extensions["propresenter_client"] = fake_client return app.test_client(), fake_client + def test_api_config_returns_public_runtime_settings(self): + client, _fake_client = self.make_client() + + response = client.get("/api/config") + + self.assertEqual(200, response.status_code) + self.assertEqual( + {"baseUrl": "http://127.0.0.1:1025", "pollTimeoutMs": 2500}, + response.get_json(), + ) + + def test_api_health_reports_successful_version_probe(self): + client, fake_client = self.make_client() + fake_client.responses["/version"] = (200, {"version": "18.0"}) + + response = client.get("/api/health") + + self.assertEqual(200, response.status_code) + self.assertEqual({"ok": True, "path": "/version", "data": {"version": "18.0"}}, response.get_json()) + + def test_api_health_reports_failed_version_probe(self): + client, fake_client = self.make_client() + fake_client.responses["/version"] = ProPresenterError("offline") + + response = client.get("/api/health") + + self.assertEqual(502, response.status_code) + self.assertEqual(False, response.get_json()["ok"]) + self.assertEqual("/version", response.get_json()["errors"][0]["path"]) + + def test_libraries_and_library_routes_proxy_to_propresenter(self): + client, fake_client = self.make_client() + fake_client.responses["/v1/libraries"] = (200, [{"uuid": "lib-1"}]) + fake_client.responses["/v1/library/lib%20one"] = (200, {"items": []}) + + libraries_response = client.get("/api/libraries") + library_response = client.get("/api/library/lib one") + + self.assertEqual(200, libraries_response.status_code) + self.assertEqual([{"uuid": "lib-1"}], libraries_response.get_json()) + self.assertEqual(200, library_response.status_code) + self.assertEqual({"items": []}, library_response.get_json()) + self.assertIn(("/v1/library/lib%20one", "GET", None), fake_client.paths) + + def test_presentation_cache_route_builds_cached_slide_payload(self): + client, fake_client = self.make_client() + fake_client.responses["/v1/presentation/deck%20uuid"] = ( + 200, + { + "presentation": { + "id": {"name": "Sunday"}, + "groups": [{"slides": [{"label": "Welcome", "notes": "Say hello"}]}], + } + }, + ) + + response = client.get("/api/presentation-cache/deck uuid") + + self.assertEqual(200, response.status_code) + payload = response.get_json() + self.assertEqual("deck uuid", payload["presentationId"]) + self.assertEqual("Sunday", payload["title"]) + self.assertEqual("Welcome", payload["slides"][0]["title"]) + self.assertEqual("Say hello", payload["slides"][0]["notes"]) + self.assertTrue(payload["slides"][0]["thumbnail"].startswith("data:image/jpeg;base64,")) + + def test_presentation_fingerprint_route_returns_404_when_presentation_missing(self): + client, fake_client = self.make_client() + fake_client.responses["/v1/presentation/missing"] = (404, {"error": "missing"}) + + response = client.get("/api/presentation-fingerprint/missing") + + self.assertEqual(404, response.status_code) + self.assertEqual({"error": "Could not read presentation from ProPresenter"}, response.get_json()) + + def test_trigger_next_and_previous_proxy_to_propresenter(self): + client, fake_client = self.make_client() + + next_response = client.post("/api/trigger/next") + previous_response = client.post("/api/trigger/previous") + + self.assertEqual(204, next_response.status_code) + self.assertEqual(204, previous_response.status_code) + self.assertEqual( + [("/v1/trigger/next", "GET", None), ("/v1/trigger/previous", "GET", None)], + fake_client.paths, + ) + def test_trigger_slide_uses_presentation_uuid_trigger_endpoint(self): client, fake_client = self.make_client() @@ -58,6 +159,22 @@ def test_trigger_slide_url_encodes_presentation_id_and_index(self): fake_client.paths, ) + def test_propresenter_errors_are_returned_as_json(self): + client, fake_client = self.make_client() + fake_client.responses["/v1/trigger/next"] = ProPresenterError( + "ProPresenter unavailable", + status_code=503, + details={"host": "127.0.0.1"}, + ) + + response = client.post("/api/trigger/next") + + self.assertEqual(503, response.status_code) + self.assertEqual( + {"error": "ProPresenter unavailable", "details": {"host": "127.0.0.1"}}, + response.get_json(), + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_services.py b/tests/test_services.py index f99640e..708437b 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,7 +1,21 @@ import unittest from unittest.mock import patch -from propresenter_notes.services import build_presentation_cache, presentation_slides +from propresenter_notes.client import ProPresenterError +from propresenter_notes.services import ( + build_presentation_cache, + find_notes, + find_slide_index, + find_slide_title, + flatten_presentations, + get_presentations, + get_slide_state, + presentation_fingerprint, + presentation_slides, + text_from_object, + try_paths, + uuid_value, +) PRESENTATION_PAYLOAD = { @@ -43,12 +57,19 @@ class FakeClient: + def __init__(self, responses=None): + self.responses = responses or {} + self.paths = [] + def fetch(self, api_path): - self.last_path = api_path - return 200, PRESENTATION_PAYLOAD + self.paths.append(api_path) + response = self.responses.get(api_path, (200, PRESENTATION_PAYLOAD)) + if isinstance(response, Exception): + raise response + return response -class PresentationCacheTests(unittest.TestCase): +class PresentationParsingTests(unittest.TestCase): def test_presentation_slides_flattens_grouped_propresenter_payload(self): slides = presentation_slides(PRESENTATION_PAYLOAD) @@ -57,6 +78,60 @@ def test_presentation_slides_flattens_grouped_propresenter_payload(self): self.assertEqual("Slide notes for slide 1", slides[1]["notes"]) self.assertEqual("slide notes for slide 2", slides[2]["notes"]) + def test_presentation_slides_supports_top_level_lists_and_alternate_keys(self): + self.assertEqual([{"name": "Slide"}], presentation_slides([{"name": "Slide"}])) + self.assertEqual([{"name": "Cue"}], presentation_slides({"presentation": {"cues": [{"name": "Cue"}]}})) + self.assertEqual([], presentation_slides({"presentation": {"groups": []}})) + + def test_text_from_object_collects_nested_text(self): + value = [{"text": "Line one"}, {"body": "Line two"}, 7] + + self.assertEqual("Line one\nLine two\n7", text_from_object(value)) + + def test_find_notes_finds_preferred_note_keys_recursively_and_avoids_cycles(self): + node = {"children": [{"slideNotes": {"plainText": "Speaker note"}}]} + node["cycle"] = node + + self.assertEqual("Speaker note", find_notes(node)) + + def test_find_slide_index_uses_first_non_negative_index_candidate(self): + self.assertEqual(3, find_slide_index({"index": "3"})) + self.assertEqual(5, find_slide_index({"presentation": {"slideIndex": 5}})) + self.assertEqual(0, find_slide_index({"index": -1, "cue": {"index": "bad"}})) + + def test_find_slide_title_uses_label_name_text_and_nested_values(self): + self.assertEqual("Welcome", find_slide_title({"label": " Welcome "})) + self.assertEqual("Cue title", find_slide_title({"cue": {"name": "Cue title"}})) + self.assertEqual("", find_slide_title([])) + + def test_uuid_value_reads_nested_uuid_name_index_or_scalar(self): + self.assertEqual("abc", uuid_value({"uuid": {"uuid": "abc"}})) + self.assertEqual("Library", uuid_value({"name": "Library"})) + self.assertEqual("4", uuid_value({"index": 4})) + self.assertEqual("plain", uuid_value("plain")) + + def test_flatten_presentations_walks_nested_libraries(self): + libraries = [ + { + "id": {"uuid": "lib-1"}, + "items": [{"id": {"uuid": "deck-1", "name": "Deck 1"}}, {"no_id": True}], + "children": [{"id": {"uuid": "child-lib"}, "items": [{"id": {"uuid": "deck-2"}}]}], + } + ] + + flattened = flatten_presentations(libraries) + + self.assertEqual(2, len(flattened)) + self.assertEqual("lib-1", flattened[0]["libraryId"]) + self.assertEqual({"uuid": "deck-1", "name": "Deck 1"}, flattened[0]["presentation"]) + self.assertEqual("lib-1", flattened[1]["libraryId"]) + + def test_presentation_fingerprint_is_stable_for_key_ordering(self): + self.assertEqual(presentation_fingerprint({"b": 2, "a": 1}), presentation_fingerprint({"a": 1, "b": 2})) + self.assertEqual("", presentation_fingerprint(None)) + + +class PresentationServiceTests(unittest.TestCase): @patch("propresenter_notes.services.get_presentation_thumbnail", return_value=b"jpeg-bytes") def test_build_presentation_cache_uses_grouped_slides_and_titles(self, _thumbnail): cached = build_presentation_cache(FakeClient(), "70623736-5b63-4dd6-83e5-2c01c2897240") @@ -68,6 +143,91 @@ def test_build_presentation_cache_uses_grouped_slides_and_titles(self, _thumbnai self.assertEqual("slide notes for slide 2", cached["slides"][2]["notes"]) self.assertTrue(cached["slides"][0]["thumbnail"].startswith("data:image/jpeg;base64,")) + def test_build_presentation_cache_raises_when_presentation_missing(self): + client = FakeClient({"/v1/presentation/missing": (404, {"error": "missing"})}) + + with self.assertRaises(ProPresenterError) as raised: + build_presentation_cache(client, "missing") + + self.assertEqual(404, raised.exception.status_code) + + def test_try_paths_returns_first_success_and_records_failures(self): + client = FakeClient({"/bad": RuntimeError("nope"), "/good": (200, {"ok": True})}) + + result = try_paths(client, ["", "/bad", "/good"]) + + self.assertEqual({"ok": True, "path": "/good", "data": {"ok": True}}, result) + self.assertEqual(["/bad", "/good"], client.paths) + + def test_try_paths_returns_errors_when_all_paths_fail(self): + client = FakeClient({"/bad": RuntimeError("nope")}) + + result = try_paths(client, ["/bad"]) + + self.assertFalse(result["ok"]) + self.assertEqual([{"path": "/bad", "message": "nope"}], result["errors"]) + + def test_get_presentations_fetches_each_library_uuid(self): + client = FakeClient( + { + "/v1/libraries": (200, [{"uuid": "lib one"}, {"uuid": "lib/two"}]), + "/v1/library/lib%20one": (200, {"items": [{"uuid": "deck-1", "name": "Deck 1"}]}), + "/v1/library/lib%2Ftwo": RuntimeError("offline"), + } + ) + + presentations = get_presentations(client) + + self.assertEqual(1, len(presentations)) + self.assertEqual({"uuid": "deck-1", "name": "Deck 1"}, presentations[0]["presentation"]) + self.assertEqual(["/v1/libraries", "/v1/library/lib%20one", "/v1/library/lib%2Ftwo"], client.paths) + + def test_get_presentations_falls_back_to_flattening_library_payload(self): + libraries = [{"id": {"uuid": "lib-1"}, "items": [{"id": {"uuid": "deck-1"}}]}] + client = FakeClient({"/v1/libraries": (200, libraries), "/v1/library/lib-1": RuntimeError("old api")}) + + presentations = get_presentations(client) + + self.assertEqual("lib-1", presentations[0]["libraryId"]) + self.assertEqual({"uuid": "deck-1"}, presentations[0]["presentation"]) + + def test_get_slide_state_uses_status_payload(self): + client = FakeClient({"/v1/status/slide": (200, {"index": 2, "label": "Verse", "notes": "Sing softly"})}) + + state = get_slide_state(client, "lib", "deck") + + self.assertEqual("/v1/status/slide", state["sourcePath"]) + self.assertEqual(2, state["slideIndex"]) + self.assertEqual(3, state["slideNumber"]) + self.assertEqual("Verse", state["title"]) + self.assertEqual("Sing softly", state["notes"]) + + def test_get_slide_state_falls_back_to_presentation_details_for_title_and_notes(self): + client = FakeClient( + { + "/v1/status/slide": (200, {"index": 1}), + "/v1/presentation/deck%20uuid": ( + 200, + {"presentation": {"groups": [{"slides": [{"label": "One"}, {"label": "Two", "notes": "Note two"}]}]}}, + ), + } + ) + + state = get_slide_state(client, "lib uuid", "deck uuid") + + self.assertEqual("Two", state["title"]) + self.assertEqual("Note two", state["notes"]) + self.assertEqual(["/v1/status/slide", "/v1/presentation/deck%20uuid"], client.paths) + + def test_get_slide_state_raises_when_no_status_path_works(self): + client = FakeClient({"/v1/status/slide": RuntimeError("offline"), "/v1/presentation/active": RuntimeError("offline")}) + + with self.assertRaises(ProPresenterError) as raised: + get_slide_state(client, "", "") + + self.assertEqual("Could not read slide status from ProPresenter", str(raised.exception)) + self.assertEqual(2, len(raised.exception.details["attempts"])) + if __name__ == "__main__": unittest.main()