diff options
Diffstat (limited to 'packages/meshbay-node/tests')
5 files changed, 214 insertions, 34 deletions
diff --git a/packages/meshbay-node/tests/test_media_meta_request.py b/packages/meshbay-node/tests/test_media_meta_request.py index 9a1c5aa..0392845 100644 --- a/packages/meshbay-node/tests/test_media_meta_request.py +++ b/packages/meshbay-node/tests/test_media_meta_request.py @@ -64,6 +64,9 @@ def _session(index, media_cache, tmdb_client): "media_cache": media_cache, "tmdb_client": tmdb_client, "tmdb_enabled": True, + # A configured node: the language gate (§9.7) holds every TMDB fetch + # until a language is chosen, so these routing tests must set one. + "daemon_state": {"tmdb_language": "en-US"}, } session._group_id = None session.sent = [] diff --git a/packages/meshbay-node/tests/test_platform.py b/packages/meshbay-node/tests/test_platform.py index 5c80c5b..bebe8d9 100644 --- a/packages/meshbay-node/tests/test_platform.py +++ b/packages/meshbay-node/tests/test_platform.py @@ -427,39 +427,8 @@ def test_source_checkout_has_no_packaged_default(monkeypatch, tmp_path): assert plat.packaged_default_env() is None -def test_install_node_env_copies_once(monkeypatch, tmp_path): - src = tmp_path / "default.env" - src.write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJfirst\n") - monkeypatch.setattr(plat, "packaged_default_env", lambda: src) - cfg = tmp_path / "config" - cfg.mkdir() - - written = plat.install_node_env(cfg) - assert written == cfg / "node.env" - assert "eyJfirst" in written.read_text() - - -def test_install_node_env_never_overwrites_operator_values(monkeypatch, tmp_path): - """An existing node.env holds the operator's own token; clobbering it would - silently downgrade a configured node to the shared default.""" - src = tmp_path / "default.env" - src.write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJpackaged\n") - monkeypatch.setattr(plat, "packaged_default_env", lambda: src) - cfg = tmp_path / "config" - cfg.mkdir() - (cfg / "node.env").write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJoperator\n") - - assert plat.install_node_env(cfg) is None - assert "eyJoperator" in (cfg / "node.env").read_text() - - -def test_install_node_env_is_a_noop_without_a_package(monkeypatch, tmp_path): - monkeypatch.setattr(plat, "packaged_default_env", lambda: None) - assert plat.install_node_env(tmp_path) is None - assert not (tmp_path / "node.env").exists() - - def test_load_node_env_sets_names(monkeypatch, tmp_path): + monkeypatch.setattr(plat, "packaged_default_env", lambda: None) (tmp_path / "node.env").write_text( "# a comment\n" "\n" @@ -482,5 +451,33 @@ def test_load_node_env_does_not_override_the_environment(monkeypatch, tmp_path): assert os.environ["MESHBAY_TMDB_DEFAULT_TOKEN"] == "eyJfromenv" -def test_load_node_env_tolerates_a_missing_file(tmp_path): +def test_load_node_env_tolerates_a_missing_file(monkeypatch, tmp_path): + monkeypatch.setattr(plat, "packaged_default_env", lambda: None) assert plat.load_node_env(tmp_path) == 0 + + +def test_load_node_env_reads_the_packaged_default_without_a_node_env(monkeypatch, tmp_path): + """A node onboarded by the desktop client has no node.env: nothing ran + `init` to copy one. The packaged token must reach it anyway.""" + src = tmp_path / "default.env" + src.write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJpackaged\n") + monkeypatch.setattr(plat, "packaged_default_env", lambda: src) + monkeypatch.delenv("MESHBAY_TMDB_DEFAULT_TOKEN", raising=False) + cfg = tmp_path / "config" + cfg.mkdir() + + assert plat.load_node_env(cfg) == 1 + assert os.environ["MESHBAY_TMDB_DEFAULT_TOKEN"] == "eyJpackaged" + + +def test_load_node_env_prefers_the_operator_node_env(monkeypatch, tmp_path): + src = tmp_path / "default.env" + src.write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJpackaged\n") + monkeypatch.setattr(plat, "packaged_default_env", lambda: src) + monkeypatch.delenv("MESHBAY_TMDB_DEFAULT_TOKEN", raising=False) + cfg = tmp_path / "config" + cfg.mkdir() + (cfg / "node.env").write_text("MESHBAY_TMDB_DEFAULT_TOKEN=eyJoperator\n") + + plat.load_node_env(cfg) + assert os.environ["MESHBAY_TMDB_DEFAULT_TOKEN"] == "eyJoperator" diff --git a/packages/meshbay-node/tests/test_season_and_search_requests.py b/packages/meshbay-node/tests/test_season_and_search_requests.py index 7e81e63..bec7df9 100644 --- a/packages/meshbay-node/tests/test_season_and_search_requests.py +++ b/packages/meshbay-node/tests/test_season_and_search_requests.py @@ -19,7 +19,10 @@ pytestmark = pytest.mark.asyncio def _session(media_cache=None, tmdb_client=None) -> WebRTCPeerSession: session = WebRTCPeerSession.__new__(WebRTCPeerSession) - session._ctx = {"media_cache": media_cache, "tmdb_client": tmdb_client} + # daemon_state carries a configured language: the gate (§9.7) holds every + # TMDB fetch until one is set, so these fetch/search tests must set one. + session._ctx = {"media_cache": media_cache, "tmdb_client": tmdb_client, + "daemon_state": {"tmdb_language": "en-US"}} session._group_id = None # Set because production always has one: `_dispatch_message` refuses every # message until the handshake settles `_user_id`, so a session reaching any diff --git a/packages/meshbay-node/tests/test_tmdb_language_change_clears_cache.py b/packages/meshbay-node/tests/test_tmdb_language_change_clears_cache.py new file mode 100644 index 0000000..cf1ecdc --- /dev/null +++ b/packages/meshbay-node/tests/test_tmdb_language_change_clears_cache.py @@ -0,0 +1,80 @@ +""" +Changing the node's TMDB language wipes the cached fiches (`ops.set_tmdb_config`). + +The cache is keyed by TMDB id alone and records no language, so a fiche fetched +under the old language would be served for its whole 30-day TTL. An operator who +sets the language *after* browsing the library once — the ordinary order, since +browsing is what triggers the lazy fetch — would keep seeing the old language +otherwise (found live: a whole library indexed in English before "Français" was +chosen, 2026-09-26). Only the language change clears; a no-op re-set or a +token-only change must leave the cache alone, or every unrelated settings save +would throw the library's metadata away. +""" + +import pytest +from meshbay_node import ops +from meshbay_node.media_cache import MediaCache +from meshbay_node.roster import Roster + +pytestmark = pytest.mark.asyncio + + +async def _state(tmp_path): + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + cache = MediaCache(db_path=tmp_path / "media_cache.db") + await cache.open() + state = {"roster": roster, "media_cache": cache, "node_user_id": "operator"} + return state, roster, cache + + +async def _seed(cache): + await cache.set_tmdb_meta("1668", "tv", {"name": "Friends"}) + await cache.set_season_meta("1668", 1, {"overview": "Season one"}) + + +async def test_changing_language_clears_the_metadata_cache(tmp_path): + state, roster, cache = await _state(tmp_path) + try: + await ops.set_tmdb_config(state, language="") # start at default/English + await _seed(cache) + + await ops.set_tmdb_config(state, language="fr-FR") + + assert await cache.get_tmdb_meta("1668", "tv") is None + assert await cache.get_season_meta("1668", 1) is None + finally: + await roster.close() + await cache.close() + + +async def test_re_setting_the_same_language_keeps_the_cache(tmp_path): + state, roster, cache = await _state(tmp_path) + try: + await ops.set_tmdb_config(state, language="fr-FR") + await _seed(cache) + + await ops.set_tmdb_config(state, language="fr-FR") + + assert await cache.get_tmdb_meta("1668", "tv") == {"name": "Friends"} + assert await cache.get_season_meta("1668", 1) == {"overview": "Season one"} + finally: + await roster.close() + await cache.close() + + +async def test_token_only_change_keeps_the_cache(tmp_path): + state, roster, cache = await _state(tmp_path) + try: + await ops.set_tmdb_config(state, language="fr-FR") + await _seed(cache) + + # language=None means "leave the language" — not a language change, + # so the fiches stay. + await ops.set_tmdb_config(state, token="a-custom-token") + + assert await cache.get_tmdb_meta("1668", "tv") == {"name": "Friends"} + assert await cache.get_season_meta("1668", 1) == {"overview": "Season one"} + finally: + await roster.close() + await cache.close() diff --git a/packages/meshbay-node/tests/test_tmdb_language_gate.py b/packages/meshbay-node/tests/test_tmdb_language_gate.py new file mode 100644 index 0000000..cd43fef --- /dev/null +++ b/packages/meshbay-node/tests/test_tmdb_language_gate.py @@ -0,0 +1,97 @@ +""" +The node does not query TMDB until a language is configured (§9.7). + +Browsing the Videos tab is what triggers the lazy `media_meta_req`, and it +routinely happens before the operator has opened the settings and chosen a +language. Querying then would fetch the whole library in TMDB's English default +and throw it away the moment a language was picked — double the requests against +TMDB's rate limit, for a result nobody asked for (found live 2026-09-26: a whole +library indexed in English before "Français" was chosen). So an unset language +answers confidence 0 and makes no call; a set language fetches once, in it. +""" + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_common.protocol import IndexEntry +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.media_cache import MediaCache +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +pytestmark = pytest.mark.asyncio + + +class CountingTmdbClient: + def __init__(self): + self.calls = 0 + + async def search_movie(self, title, year=None): + self.calls += 1 + return {"id": 42, "title": title, "release_date": "2001-01-01"}, 1.0 + + async def search_tv(self, title, year=None): + self.calls += 1 + return {"id": 43, "name": title, "first_air_date": "2001-01-01"}, 1.0 + + async def movie_details(self, tmdb_id, language=None): + self.calls += 1 + return {"title": "A Film", "genres": [{"name": "Drama"}], + "poster_path": "/p.jpg", "overview": "x"} + + async def movie_credits(self, tmdb_id): + return {"cast": [], "crew": []} + + async def fetch_image(self, url): + return b"img" + + @staticmethod + def poster_url(path): + return f"https://image.tmdb.org/t/p/w500{path}" + + +@pytest.fixture +async def media_cache(tmp_path): + c = MediaCache(db_path=tmp_path / "media_cache.db") + await c.open() + yield c + await c.close() + + +def _session(media_cache, tmdb_client, language): + index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) + entry = IndexEntry(id="f1", name="Some.Film.2001.mkv", path="movies", size=1, + type="video", added_at=0, display_title="Some Film") + index.add_entry(entry) + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = { + "index": index, + "media_cache": media_cache, + "tmdb_client": tmdb_client, + "tmdb_enabled": True, + "daemon_state": {"tmdb_language": language}, + } + session._group_id = None + session.sent = [] + session._send = session.sent.append + return session, entry + + +async def test_no_language_makes_no_tmdb_call(media_cache): + client = CountingTmdbClient() + session, entry = _session(media_cache, client, language="") + + await session._do_media_meta_request({"file_id": entry.id}) + + assert client.calls == 0 + assert session.sent[-1]["confidence"] == 0 + # Nothing cached, so a later request in a real language still starts clean. + assert await media_cache.get_file_tmdb(entry.id) is None + + +async def test_a_configured_language_fetches(media_cache): + client = CountingTmdbClient() + session, entry = _session(media_cache, client, language="fr-FR") + + await session._do_media_meta_request({"file_id": entry.id}) + + assert client.calls > 0 + assert session.sent[-1].get("tmdb_id") == "42" |