From b5d22c87c13ae3dad82d6ce006bfe10f42333ee2 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Sun, 14 Sep 2025 02:01:20 -0600 Subject: [PATCH 1/3] test: add negative cache and settings tests --- pyproject.toml | 2 +- tests/test_cache.py | 20 ++++++++++++++++++++ tests/test_config.py | 16 ++++++++++++++++ tests/test_gather_in_batches.py | 5 +++++ tests/test_imdb_cache.py | 26 ++++++++++++++++++++++++++ uv.lock | 2 +- 6 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests/test_cache.py create mode 100644 tests/test_config.py create mode 100644 tests/test_imdb_cache.py diff --git a/pyproject.toml b/pyproject.toml index 4d297a9..a54461a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mcp-plex" -version = "0.26.22" +version = "0.26.23" description = "Plex-Oriented Model Context Protocol Server" requires-python = ">=3.11,<3.13" diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..0205794 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,20 @@ +from mcp_plex.cache import MediaCache + + +def test_media_cache_eviction_and_clear(): + cache = MediaCache(size=2) + cache.set_payload("a", {"id": 1}) + cache.set_payload("b", {"id": 2}) + cache.get_payload("a") + cache.set_payload("c", {"id": 3}) + assert cache.get_payload("a") == {"id": 1} + assert cache.get_payload("b") is None + assert cache.get_payload("c") == {"id": 3} + + assert cache.get_poster("missing") is None + cache.set_poster("p1", "poster") + cache.set_background("bg1", "background") + cache.clear() + assert cache.get_payload("a") is None + assert cache.get_poster("p1") is None + assert cache.get_background("bg1") is None diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..7770f98 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,16 @@ +import pytest +from pydantic import ValidationError + +from mcp_plex.config import Settings + + +def test_settings_env_override(monkeypatch): + monkeypatch.setenv("qdrant_port", "7000") + settings = Settings() + assert settings.qdrant_port == 7000 + + +def test_settings_invalid_cache_size(monkeypatch): + monkeypatch.setenv("CACHE_SIZE", "notint") + with pytest.raises(ValidationError): + Settings() diff --git a/tests/test_gather_in_batches.py b/tests/test_gather_in_batches.py index 3dcff39..57b78f7 100644 --- a/tests/test_gather_in_batches.py +++ b/tests/test_gather_in_batches.py @@ -1,5 +1,6 @@ import asyncio import logging +import pytest from mcp_plex import loader @@ -29,3 +30,7 @@ async def fake_gather(*coros): assert "Processed 4/5 items" in caplog.text assert "Processed 5/5 items" in caplog.text +def test_gather_in_batches_zero_batch_size(): + tasks = [_echo(i) for i in range(3)] + with pytest.raises(ValueError): + asyncio.run(loader._gather_in_batches(tasks, 0)) diff --git a/tests/test_imdb_cache.py b/tests/test_imdb_cache.py new file mode 100644 index 0000000..96f7ccf --- /dev/null +++ b/tests/test_imdb_cache.py @@ -0,0 +1,26 @@ +import json +from pathlib import Path + +from mcp_plex.imdb_cache import IMDbCache + + +def test_imdb_cache_loads_existing_and_persists(tmp_path: Path): + path = tmp_path / "cache.json" + path.write_text(json.dumps({"tt1": {"id": "tt1"}})) + cache = IMDbCache(path) + assert cache.get("tt1") == {"id": "tt1"} + + cache.set("tt2", {"id": "tt2"}) + assert json.loads(path.read_text()) == { + "tt1": {"id": "tt1"}, + "tt2": {"id": "tt2"}, + } + + +def test_imdb_cache_invalid_file(tmp_path: Path): + path = tmp_path / "cache.json" + path.write_text("not json") + cache = IMDbCache(path) + assert cache.get("tt1") is None + cache.set("tt1", {"id": "tt1"}) + assert cache.get("tt1") == {"id": "tt1"} diff --git a/uv.lock b/uv.lock index b85ae9e..3226dd2 100644 --- a/uv.lock +++ b/uv.lock @@ -690,7 +690,7 @@ wheels = [ [[package]] name = "mcp-plex" -version = "0.26.22" +version = "0.26.23" source = { editable = "." } dependencies = [ { name = "fastapi" }, From f300b81907a9382f8e885a03894611cdf41e9d71 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Sun, 14 Sep 2025 02:13:51 -0600 Subject: [PATCH 2/3] test: expand cache and loader coverage --- pyproject.toml | 2 +- tests/test_cache.py | 34 +++++---- tests/test_config.py | 4 +- tests/test_imdb_cache.py | 36 +++++++--- tests/test_loader_cli.py | 29 ++++++++ tests/test_loader_integration.py | 25 +++++++ tests/test_loader_unit.py | 120 +++++++++++++++++++++++++++++-- uv.lock | 2 +- 8 files changed, 224 insertions(+), 28 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a54461a..1fa27df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mcp-plex" -version = "0.26.23" +version = "0.26.24" description = "Plex-Oriented Model Context Protocol Server" requires-python = ">=3.11,<3.13" diff --git a/tests/test_cache.py b/tests/test_cache.py index 0205794..a06724d 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -3,18 +3,28 @@ def test_media_cache_eviction_and_clear(): cache = MediaCache(size=2) - cache.set_payload("a", {"id": 1}) - cache.set_payload("b", {"id": 2}) - cache.get_payload("a") - cache.set_payload("c", {"id": 3}) - assert cache.get_payload("a") == {"id": 1} - assert cache.get_payload("b") is None - assert cache.get_payload("c") == {"id": 3} + cache.set_payload( + "tt0111161", {"id": "tt0111161", "title": "The Shawshank Redemption"} + ) + cache.set_payload("tt0068646", {"id": "tt0068646", "title": "The Godfather"}) + cache.get_payload("tt0111161") + cache.set_payload("tt1375666", {"id": "tt1375666", "title": "Inception"}) + assert cache.get_payload("tt0111161") == { + "id": "tt0111161", + "title": "The Shawshank Redemption", + } + assert cache.get_payload("tt0068646") is None + assert cache.get_payload("tt1375666") == { + "id": "tt1375666", + "title": "Inception", + } assert cache.get_poster("missing") is None - cache.set_poster("p1", "poster") - cache.set_background("bg1", "background") + cache.set_poster("tt0111161", "https://example.com/shawshank.jpg") + cache.set_background("tt0111161", "https://example.com/shawshank-bg.jpg") + assert cache.get_poster("tt0111161") == "https://example.com/shawshank.jpg" + assert cache.get_background("tt0111161") == "https://example.com/shawshank-bg.jpg" cache.clear() - assert cache.get_payload("a") is None - assert cache.get_poster("p1") is None - assert cache.get_background("bg1") is None + assert cache.get_payload("tt0111161") is None + assert cache.get_poster("tt0111161") is None + assert cache.get_background("tt0111161") is None diff --git a/tests/test_config.py b/tests/test_config.py index 7770f98..6f64308 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,9 +5,9 @@ def test_settings_env_override(monkeypatch): - monkeypatch.setenv("qdrant_port", "7000") + monkeypatch.setenv("QDRANT_PORT", "7001") settings = Settings() - assert settings.qdrant_port == 7000 + assert settings.qdrant_port == 7001 def test_settings_invalid_cache_size(monkeypatch): diff --git a/tests/test_imdb_cache.py b/tests/test_imdb_cache.py index 96f7ccf..9d58f09 100644 --- a/tests/test_imdb_cache.py +++ b/tests/test_imdb_cache.py @@ -6,14 +6,34 @@ def test_imdb_cache_loads_existing_and_persists(tmp_path: Path): path = tmp_path / "cache.json" - path.write_text(json.dumps({"tt1": {"id": "tt1"}})) + path.write_text( + json.dumps( + { + "tt0111161": { + "id": "tt0111161", + "primaryTitle": "The Shawshank Redemption", + } + } + ) + ) cache = IMDbCache(path) - assert cache.get("tt1") == {"id": "tt1"} + assert cache.get("tt0111161") == { + "id": "tt0111161", + "primaryTitle": "The Shawshank Redemption", + } - cache.set("tt2", {"id": "tt2"}) + cache.set( + "tt0068646", {"id": "tt0068646", "primaryTitle": "The Godfather"} + ) assert json.loads(path.read_text()) == { - "tt1": {"id": "tt1"}, - "tt2": {"id": "tt2"}, + "tt0111161": { + "id": "tt0111161", + "primaryTitle": "The Shawshank Redemption", + }, + "tt0068646": { + "id": "tt0068646", + "primaryTitle": "The Godfather", + }, } @@ -21,6 +41,6 @@ def test_imdb_cache_invalid_file(tmp_path: Path): path = tmp_path / "cache.json" path.write_text("not json") cache = IMDbCache(path) - assert cache.get("tt1") is None - cache.set("tt1", {"id": "tt1"}) - assert cache.get("tt1") == {"id": "tt1"} + assert cache.get("tt0111161") is None + cache.set("tt0111161", {"id": "tt0111161"}) + assert cache.get("tt0111161") == {"id": "tt0111161"} diff --git a/tests/test_loader_cli.py b/tests/test_loader_cli.py index b0e725b..7236eea 100644 --- a/tests/test_loader_cli.py +++ b/tests/test_loader_cli.py @@ -1,4 +1,6 @@ import asyncio +import runpy +import sys import pytest from click.testing import CliRunner @@ -64,6 +66,26 @@ async def invoke(): asyncio.run(invoke()) +def test_run_requires_tmdb_api_key(monkeypatch): + monkeypatch.setattr(loader, "PlexServer", object) + + async def invoke(): + await loader.run("http://localhost", "token", None, None, None, None) + + with pytest.raises(RuntimeError, match="TMDB_API_KEY must be provided"): + asyncio.run(invoke()) + + +def test_run_requires_plexapi(monkeypatch): + monkeypatch.setattr(loader, "PlexServer", None) + + async def invoke(): + await loader.run("http://localhost", "token", "key", None, None, None) + + with pytest.raises(RuntimeError, match="plexapi is required for live loading"): + asyncio.run(invoke()) + + def test_cli_model_overrides(monkeypatch): captured: dict[str, str] = {} @@ -114,3 +136,10 @@ async def fake_run(*args, **kwargs): assert captured["dense"] == "foo" assert captured["sparse"] == "bar" + + +def test_loader_script_entrypoint(monkeypatch): + monkeypatch.setattr(sys, "argv", ["loader", "--help"]) + with pytest.raises(SystemExit) as exc: + runpy.run_module("mcp_plex.loader", run_name="__main__") + assert exc.value.code == 0 diff --git a/tests/test_loader_integration.py b/tests/test_loader_integration.py index dec52a7..573bafd 100644 --- a/tests/test_loader_integration.py +++ b/tests/test_loader_integration.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import json from pathlib import Path from qdrant_client.async_qdrant_client import AsyncQdrantClient @@ -53,3 +54,27 @@ def test_run_writes_points(monkeypatch): ) +def test_run_processes_imdb_queue(monkeypatch, tmp_path): + monkeypatch.setattr(loader, "AsyncQdrantClient", CaptureClient) + queue_file = tmp_path / "queue.json" + queue_file.write_text(json.dumps(["tt0111161"])) + sample_dir = Path(__file__).resolve().parents[1] / "sample-data" + + async def fake_fetch(client, imdb_id): + return None + + monkeypatch.setattr(loader, "_fetch_imdb", fake_fetch) + + asyncio.run( + loader.run( + None, + None, + None, + sample_dir, + None, + None, + imdb_queue_path=queue_file, + ) + ) + + assert json.loads(queue_file.read_text()) == ["tt0111161"] diff --git a/tests/test_loader_unit.py b/tests/test_loader_unit.py index de46166..d01387f 100644 --- a/tests/test_loader_unit.py +++ b/tests/test_loader_unit.py @@ -1,4 +1,6 @@ import asyncio +import builtins +import importlib import json import types from pathlib import Path @@ -24,6 +26,21 @@ from mcp_plex.types import TMDBSeason, TMDBShow +def test_loader_import_fallback(monkeypatch): + real_import = builtins.__import__ + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name.startswith("plexapi"): + raise ModuleNotFoundError + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", fake_import) + module = importlib.reload(loader) + assert module.PlexServer is None + assert module.PlexPartialObject is object + importlib.reload(loader) + + def test_extract_external_ids(): guid_objs = [ types.SimpleNamespace(id="imdb://tt0133093"), @@ -234,6 +251,38 @@ async def main(): assert all(len(c) <= 5 for c in calls) +def test_fetch_imdb_batch_all_cached(monkeypatch, tmp_path): + cache_path = tmp_path / "cache.json" + cache_path.write_text( + json.dumps( + { + "tt0111161": { + "id": "tt0111161", + "type": "movie", + "primaryTitle": "The Shawshank Redemption", + }, + "tt0068646": { + "id": "tt0068646", + "type": "movie", + "primaryTitle": "The Godfather", + }, + } + ) + ) + monkeypatch.setattr(loader, "_imdb_cache", IMDbCache(cache_path)) + + async def error_mock(request): + raise AssertionError("network should not be called") + + async def main(): + async with httpx.AsyncClient(transport=httpx.MockTransport(error_mock)) as client: + result = await _fetch_imdb_batch(client, ["tt0111161", "tt0068646"]) + assert result["tt0111161"].primaryTitle == "The Shawshank Redemption" + assert result["tt0068646"].primaryTitle == "The Godfather" + + asyncio.run(main()) + + def test_fetch_imdb_retries_on_429(monkeypatch, tmp_path): cache_path = tmp_path / "cache.json" monkeypatch.setattr(loader, "_imdb_cache", IMDbCache(cache_path)) @@ -279,17 +328,24 @@ async def first_transport(request): return httpx.Response(429) async def second_transport(request): - return httpx.Response(200, json={"id": "tt1", "type": "movie", "primaryTitle": "T"}) + return httpx.Response( + 200, + json={ + "id": "tt0111161", + "type": "movie", + "primaryTitle": "The Shawshank Redemption", + }, + ) async def first_run(): _load_imdb_retry_queue(queue_path) async with httpx.AsyncClient(transport=httpx.MockTransport(first_transport)) as client: await _process_imdb_retry_queue(client) - await _fetch_imdb(client, "tt1") + await _fetch_imdb(client, "tt0111161") _persist_imdb_retry_queue(queue_path) asyncio.run(first_run()) - assert json.loads(queue_path.read_text()) == ["tt1"] + assert json.loads(queue_path.read_text()) == ["tt0111161"] async def second_run(): _load_imdb_retry_queue(queue_path) @@ -299,7 +355,33 @@ async def second_run(): asyncio.run(second_run()) assert json.loads(queue_path.read_text()) == [] - assert loader._imdb_cache.get("tt1") is not None + assert loader._imdb_cache.get("tt0111161") is not None + + +def test_load_imdb_retry_queue_invalid_json(tmp_path): + path = tmp_path / "queue.json" + path.write_text("not json") + _load_imdb_retry_queue(path) + assert loader._imdb_retry_queue is not None + assert loader._imdb_retry_queue.qsize() == 0 + + +def test_process_imdb_retry_queue_requeues(monkeypatch): + queue: asyncio.Queue[str] = asyncio.Queue() + queue.put_nowait("tt0111161") + monkeypatch.setattr(loader, "_imdb_retry_queue", queue) + + async def fake_fetch(client, imdb_id): + return None + + monkeypatch.setattr(loader, "_fetch_imdb", fake_fetch) + + async def run_test(): + async with httpx.AsyncClient() as client: + await _process_imdb_retry_queue(client) + + asyncio.run(run_test()) + assert queue.qsize() == 1 def test_resolve_tmdb_season_number_matches_name(): @@ -334,3 +416,33 @@ def test_resolve_tmdb_season_number_parent_year_fallback(): seasons=[TMDBSeason(season_number=5, name="Season 5", air_date="2018-06-01")], ) assert resolve_tmdb_season_number(show, episode) == 5 + + +def test_resolve_tmdb_season_number_numeric_match(): + episode = types.SimpleNamespace(parentIndex=2, parentTitle="Season 2") + show = TMDBShow( + id=1, + name="Show", + seasons=[TMDBSeason(season_number=2, name="Season 2")], + ) + assert resolve_tmdb_season_number(show, episode) == 2 + + +def test_resolve_tmdb_season_number_title_year(): + episode = types.SimpleNamespace(parentTitle="2018") + show = TMDBShow( + id=1, + name="Show", + seasons=[TMDBSeason(season_number=7, name="Season 7", air_date="2018-02-03")], + ) + assert resolve_tmdb_season_number(show, episode) == 7 + + +def test_resolve_tmdb_season_number_parent_index_str(): + episode = types.SimpleNamespace(parentIndex="3") + assert resolve_tmdb_season_number(None, episode) == 3 + + +def test_resolve_tmdb_season_number_parent_title_digit(): + episode = types.SimpleNamespace(parentTitle="4") + assert resolve_tmdb_season_number(None, episode) == 4 diff --git a/uv.lock b/uv.lock index 3226dd2..e40423d 100644 --- a/uv.lock +++ b/uv.lock @@ -690,7 +690,7 @@ wheels = [ [[package]] name = "mcp-plex" -version = "0.26.23" +version = "0.26.24" source = { editable = "." } dependencies = [ { name = "fastapi" }, From 7bbf46dec399c420242412b7b5b3d5b2db0ef840 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Sun, 14 Sep 2025 02:16:18 -0600 Subject: [PATCH 3/3] chore: log level info --- mcp_plex/loader.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mcp_plex/loader.py b/mcp_plex/loader.py index 9bf1abe..11e2145 100644 --- a/mcp_plex/loader.py +++ b/mcp_plex/loader.py @@ -35,6 +35,7 @@ PlexPartialObject = object # type: ignore[assignment] +logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) T = TypeVar("T")