Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions propresenter_notes/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "")
Expand Down
56 changes: 56 additions & 0 deletions tests/openapi_spec.py
Original file line number Diff line number Diff line change
@@ -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")
1 change: 1 addition & 0 deletions tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
130 changes: 130 additions & 0 deletions tests/test_swagger_contract.py
Original file line number Diff line number Diff line change
@@ -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()
Loading