From 16b68bee55133dfe3a1dbaef2c4604135089ea91 Mon Sep 17 00:00:00 2001 From: Merval Date: Sat, 30 May 2026 20:42:40 -0700 Subject: [PATCH] Add GitHub Actions status badge --- README.md | 4 +- propresenter_notes/services.py | 2 + tests/openapi_spec.py | 56 ++++++++++++++ tests/test_services.py | 1 + tests/test_swagger_contract.py | 130 +++++++++++++++++++++++++++++++++ 5 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 tests/openapi_spec.py create mode 100644 tests/test_swagger_contract.py diff --git a/README.md b/README.md index c02a793..95dbc5e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # ProPresenter Notes Controller for Mac +[![Tests](https://github.com/SGCCode/ProPresenterNotes/actions/workflows/tests.yml/badge.svg)](https://github.com/SGCCode/ProPresenterNotes/actions/workflows/tests.yml) + A local web app for a Mac Studio that controls ProPresenter through the ProPresenter public REST API. It does **not** require Docker or npm packages. It now runs as a Flask application. ## What it does @@ -165,7 +167,7 @@ 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. +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. The checked-in Swagger export at `tests/swagger.json` is also parsed by contract tests so app-generated ProPresenter requests and representative fixture examples stay aligned with the documented API paths. 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: diff --git a/propresenter_notes/services.py b/propresenter_notes/services.py index eda2d18..8ec4bb0 100644 --- a/propresenter_notes/services.py +++ b/propresenter_notes/services.py @@ -166,6 +166,8 @@ def nested(obj: Any, *keys: str) -> Any: def uuid_value(value: Any) -> str: if isinstance(value, dict): + if isinstance(value.get("id"), dict): + return uuid_value(value["id"]) if isinstance(value.get("uuid"), dict): return uuid_value(value["uuid"]) return str(value.get("uuid") or value.get("name") or value.get("index") or "") diff --git a/tests/openapi_spec.py b/tests/openapi_spec.py new file mode 100644 index 0000000..119cdf2 --- /dev/null +++ b/tests/openapi_spec.py @@ -0,0 +1,56 @@ +"""Helpers for loading and asserting against the checked-in ProPresenter OpenAPI spec.""" +from __future__ import annotations + +import json +import re +from functools import lru_cache +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +SPEC_PATH = Path(__file__).with_name("swagger.json") +SPEC_PREFIX = "var openapi_spec = " + + +@lru_cache(maxsize=1) +def load_openapi_spec() -> dict[str, Any]: + """Load the Swagger UI JavaScript assignment as an OpenAPI dictionary.""" + raw_spec = SPEC_PATH.read_text(encoding="utf-8").strip() + if raw_spec.startswith(SPEC_PREFIX): + raw_spec = raw_spec.removeprefix(SPEC_PREFIX) + return json.loads(raw_spec) + + +def operation_for_path(api_path: str, method: str = "get") -> tuple[str, dict[str, Any]]: + """Return the matching templated OpenAPI path and operation for an API path.""" + path_only = urlsplit(api_path).path + method = method.lower() + paths = load_openapi_spec()["paths"] + + if path_only in paths and method in paths[path_only]: + return path_only, paths[path_only][method] + + for spec_path, operations in paths.items(): + if method not in operations: + continue + if re.fullmatch(_path_template_pattern(spec_path), path_only): + return spec_path, operations[method] + + raise AssertionError(f"{method.upper()} {api_path} is not documented in {SPEC_PATH.name}") + + +def _path_template_pattern(spec_path: str) -> str: + parts = re.split(r"(\{[^/]+\})", spec_path) + return "^" + "".join("[^/]+" if part.startswith("{") else re.escape(part) for part in parts) + "$" + + +def documented_path(api_path: str, method: str = "get") -> str: + """Return the matching OpenAPI path template for a concrete API path.""" + return operation_for_path(api_path, method)[0] + + +def json_example_for_path(api_path: str, method: str = "get", status: str = "200") -> Any: + """Return an application/json response example for an API operation, if one is present.""" + _spec_path, operation = operation_for_path(api_path, method) + media_type = operation.get("responses", {}).get(status, {}).get("content", {}).get("application/json", {}) + return media_type.get("example") diff --git a/tests/test_services.py b/tests/test_services.py index 708437b..d0224bd 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -106,6 +106,7 @@ def test_find_slide_title_uses_label_name_text_and_nested_values(self): def test_uuid_value_reads_nested_uuid_name_index_or_scalar(self): self.assertEqual("abc", uuid_value({"uuid": {"uuid": "abc"}})) + self.assertEqual("lib-1", uuid_value({"id": {"uuid": "lib-1", "name": "Library"}})) self.assertEqual("Library", uuid_value({"name": "Library"})) self.assertEqual("4", uuid_value({"index": 4})) self.assertEqual("plain", uuid_value("plain")) diff --git a/tests/test_swagger_contract.py b/tests/test_swagger_contract.py new file mode 100644 index 0000000..8ecd762 --- /dev/null +++ b/tests/test_swagger_contract.py @@ -0,0 +1,130 @@ +import unittest + +from propresenter_notes import create_app +from propresenter_notes.client import clean_api_path +from propresenter_notes.config import Settings +from propresenter_notes.services import get_presentations + +from tests.openapi_spec import documented_path, json_example_for_path, load_openapi_spec + + +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)) + response = self.responses.get(api_path, (200, {})) + 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 SwaggerContractTests(unittest.TestCase): + def make_client(self): + app = create_app(Settings(ui_pin="")) + app.config.update(TESTING=True) + fake_client = FakeProPresenterClient() + app.extensions["propresenter_client"] = fake_client + return app.test_client(), fake_client + + def test_checked_in_swagger_spec_loads_as_openapi_document(self): + spec = load_openapi_spec() + + self.assertEqual("3.0.2", spec["openapi"]) + self.assertEqual("ProPresenter API", spec["info"]["title"]) + self.assertIn("/version", spec["paths"]) + self.assertIn("/v1/libraries", spec["paths"]) + self.assertIn("/v1/presentation/{uuid}/thumbnail/{index}", spec["paths"]) + + def test_client_accepts_representative_documented_swagger_paths(self): + documented_examples = [ + "/version", + "/v1/libraries", + "/v1/library/Library%20Name", + "/v1/presentation/3C39C433-5C18-4F51-B357-55BB870227C4", + "/v1/presentation/3C39C433-5C18-4F51-B357-55BB870227C4/thumbnail/0?quality=400&thumbnail_type=jpeg", + "/v1/presentation/3C39C433-5C18-4F51-B357-55BB870227C4/0/trigger", + "/v1/status/slide", + "/v1/trigger/next", + "/v1/trigger/previous", + ] + + for api_path in documented_examples: + with self.subTest(api_path=api_path): + self.assertEqual(api_path, clean_api_path(api_path)) + self.assertIsInstance(documented_path(api_path), str) + + def test_app_propresenter_requests_are_documented_by_swagger(self): + client, fake_client = self.make_client() + fake_client.responses.update( + { + "/version": (200, {"api_version": "v1"}), + "/v1/libraries": ( + 200, + [{"id": {"uuid": "lib-1", "name": "Library Name", "index": 0}}], + ), + "/v1/library/lib-1": (200, {"items": [{"uuid": "deck-1", "name": "Deck 1"}]}), + "/v1/library/Library%20Name": (200, {"items": []}), + "/v1/presentation/deck-1": ( + 200, + {"presentation": {"id": {"name": "Deck 1"}, "groups": [{"slides": [{"label": "Welcome"}]}]}}, + ), + "/v1/status/slide": (200, {"index": 0, "label": "Welcome", "notes": "Say hello"}), + "/v1/trigger/next": (204, None), + "/v1/trigger/previous": (204, None), + "/v1/presentation/deck-1/0/trigger": (204, None), + } + ) + + route_calls = [ + lambda: client.get("/api/health"), + lambda: client.get("/api/libraries"), + lambda: client.get("/api/library/Library Name"), + lambda: client.get("/api/presentations"), + lambda: client.get("/api/presentation-cache/deck-1"), + lambda: client.get("/api/presentation-fingerprint/deck-1"), + lambda: client.get("/api/slide-state?libraryId=lib-1&presentationId=deck-1"), + lambda: client.post("/api/trigger/next"), + lambda: client.post("/api/trigger/previous"), + lambda: client.post("/api/trigger/slide", json={"presentationId": "deck-1", "index": 0}), + ] + + for call_route in route_calls: + response = call_route() + self.assertLess(response.status_code, 500, response.get_data(as_text=True)) + + self.assertGreater(len(fake_client.paths), 0) + for api_path, method, _body in fake_client.paths: + with self.subTest(method=method, api_path=api_path): + self.assertIsInstance(documented_path(api_path, method), str) + + def test_swagger_libraries_example_can_drive_presentation_discovery(self): + libraries_example = json_example_for_path("/v1/libraries") + self.assertIsInstance(libraries_example, list) + library_uuid = libraries_example[0]["id"]["uuid"] + client = FakeProPresenterClient() + client.responses.update( + { + "/v1/libraries": (200, libraries_example), + f"/v1/library/{library_uuid}": (200, {"items": [{"uuid": "deck-1", "name": "Deck 1"}]}), + } + ) + + presentations = get_presentations(client) + + self.assertEqual([{"uuid": "deck-1", "name": "Deck 1"}], [item["presentation"] for item in presentations]) + self.assertEqual(["/v1/libraries", f"/v1/library/{library_uuid}"], [path for path, _method, _body in client.paths]) + + +if __name__ == "__main__": + unittest.main()