diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-29 15:57:06 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-29 15:57:06 +0200 |
| commit | 4d167e9f958d26c1db920274974ae446426928e3 (patch) | |
| tree | e056e6b4fceca76ab67586548f0aa6ed80f925bc /packages/meshbay-node/tests | |
| parent | bc40ab2c4486cca4ae63e33976151b5d7f856a84 (diff) | |
| download | meshbay-4d167e9f958d26c1db920274974ae446426928e3.tar.gz | |
feat: V13 — per-card "re-match this one file" (MNP 0.13)
A one-click alternative to the full "Fix match" search-and-pick flow, and
reachable without SSH (`meshbay-node video rematch` clears a whole group).
- MNP 0.13: tmdb_rematch / tmdb_rematch_ack (additive — an older node
logs "unknown type", the button just does nothing). OP_TMDB_REMATCH,
signed like tmdb_override (media_cache is shared node-wide).
- media_cache.drop_tmdb_match(file_id): forgets the match AND the
override marker — deliberately stronger than clear_file_tmdb, since the
operator is explicitly asking for a fresh resolution.
- webrtc_server: _do_tmdb_rematch / _admin_exec_tmdb_rematch, dispatch +
admin-response routing, broadcasts tmdb_rematch_ack.
- transport.js: rematchTmdbMatch(fileId, signFn); 'tmdb_rematch' in the
admin-op allowlist; tmdb_rematch_ack handled like tmdb_override_ack.
- video-app.js: a "Re-match" button beside "Fix match" in the detail
modal (isNodeAdmin), then bumpMediaMetaGeneration(). video.rematch_one
key in all ten locales.
docs/mediacenter.md §10.1: V8–V13 marked done.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/test_media_cache.py | 13 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_tmdb_rematch_policy.py | 147 |
2 files changed, 160 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_media_cache.py b/packages/meshbay-node/tests/test_media_cache.py index 4e65b24..f12a366 100644 --- a/packages/meshbay-node/tests/test_media_cache.py +++ b/packages/meshbay-node/tests/test_media_cache.py @@ -107,6 +107,19 @@ async def test_clear_file_tmdb_drops_one_auto_match_but_keeps_an_override(cache) @pytest.mark.asyncio +async def test_drop_tmdb_match_forgets_both_the_match_and_the_override(cache): + await cache.set_file_tmdb("f", "10", "movie") + await cache.mark_tmdb_override("f") + + await cache.drop_tmdb_match("f") + + assert await cache.get_file_tmdb("f") is None + # override marker is gone: a fresh match is now treated as ordinary + await cache.set_file_tmdb("f", "20", "movie") + assert await cache.clear_tmdb_matches(["f"]) == 1 + + +@pytest.mark.asyncio async def test_prune_file_also_clears_the_override_marker(cache): await cache.set_file_tmdb("gone", "10", "movie") await cache.mark_tmdb_override("gone") diff --git a/packages/meshbay-node/tests/test_tmdb_rematch_policy.py b/packages/meshbay-node/tests/test_tmdb_rematch_policy.py new file mode 100644 index 0000000..ff259c1 --- /dev/null +++ b/packages/meshbay-node/tests/test_tmdb_rematch_policy.py @@ -0,0 +1,147 @@ +""" +`tmdb_rematch` (§10.1/V13) — an operator dropping one file's cached TMDB +match so it re-resolves with the current matcher. Signed like +`tmdb_override` (media_cache is shared node-wide); unlike `clear_file_tmdb` +it forgets a manual override marker too, since the operator is explicitly +asking for a fresh resolution. +""" + +import hashlib + +import pytest +from conftest import one_root +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_common.adminop import OP_TMDB_REMATCH +from meshbay_common.protocol import MNP, 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 + + +def _session(tmp_path, user_id, *, operator=None): + shared = tmp_path / "shared" + shared.mkdir(exist_ok=True) + index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) + s = WebRTCPeerSession.__new__(WebRTCPeerSession) + s._ctx = {"roots": one_root(shared), "index": index, "sk_node": index.sk_node, + "node_user_id": operator} + s._group_id = None + s._user_id = user_id + s._pk_user = "" + s.sent = [] + s._send = s.sent.append + s._audit = lambda *a, **k: None + return s + + +def _entry(name): + digest = hashlib.sha256(name.encode()).hexdigest() + return IndexEntry(id=digest, name=name, path="movies", size=1, type="video", + added_at=0, display_title="Some Film") + + +async def _true(): + return True + + +async def test_missing_file_id_is_refused(tmp_path): + s = _session(tmp_path, "op", operator="op") + s._has_admin_authority = lambda: True + issued = [] + s._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + s._do_tmdb_rematch({}) + + assert not issued + assert [m for m in s.sent if m.get("type") == "error"] + + +async def test_unknown_file_id_is_refused(tmp_path): + s = _session(tmp_path, "op", operator="op") + s._has_admin_authority = lambda: True + issued = [] + s._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + s._do_tmdb_rematch({"file_id": "nope"}) + + assert not issued + assert [m for m in s.sent if m.get("type") == "error"] + + +async def test_no_authorized_key_is_refused(tmp_path): + s = _session(tmp_path, "member", operator="the-operator") + e = _entry("some.film.2001.mkv") + s._ctx["index"].add_entry(e) + s._has_admin_authority = lambda: False + + s._do_tmdb_rematch({"file_id": e.id}) + + assert [m for m in s.sent if m.get("type") == "error"] + + +async def test_a_valid_request_is_signed(tmp_path): + s = _session(tmp_path, "op", operator="op") + e = _entry("some.film.2001.mkv") + s._ctx["index"].add_entry(e) + s._has_admin_authority = lambda: True + issued = [] + s._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + s._do_tmdb_rematch({"file_id": e.id}) + + assert issued == [(OP_TMDB_REMATCH, f"file_id={e.id}")] + + +async def test_exec_drops_the_match_and_the_override_marker(tmp_path): + s = _session(tmp_path, "op", operator="op") + e = _entry("some.film.2001.mkv") + s._ctx["index"].add_entry(e) + + media_cache = MediaCache(db_path=tmp_path / "media_cache.db") + await media_cache.open() + try: + s._ctx["media_cache"] = media_cache + await media_cache.set_file_tmdb(e.id, "wrong-id", "movie") + await media_cache.mark_tmdb_override(e.id) + s._verify_admin_sig = lambda transcript, sig: _true() + peer = type("Peer", (), {"sent": []})() + peer._send = peer.sent.append + s._peer_registry = lambda: {"peer-1": peer} + + await s._admin_exec_tmdb_rematch( + {"subject": f"file_id={e.id}"}, b"transcript", b"sig") + + assert await media_cache.get_file_tmdb(e.id) is None + # the override marker is gone too, so a later group-wide rematch + # would treat a fresh match as ordinary + await media_cache.set_file_tmdb(e.id, "fresh", "movie") + assert await media_cache.clear_tmdb_matches([e.id]) == 1 + assert [m for m in peer.sent if m.get("type") == MNP.TMDB_REMATCH_ACK] + finally: + await media_cache.close() + + +async def test_exec_refuses_a_bad_signature(tmp_path): + s = _session(tmp_path, "op", operator="op") + e = _entry("some.film.2001.mkv") + s._ctx["index"].add_entry(e) + + media_cache = MediaCache(db_path=tmp_path / "media_cache.db") + await media_cache.open() + try: + s._ctx["media_cache"] = media_cache + await media_cache.set_file_tmdb(e.id, "keep-me", "movie") + + async def _false(): + return False + s._verify_admin_sig = lambda transcript, sig: _false() + + await s._admin_exec_tmdb_rematch( + {"subject": f"file_id={e.id}"}, b"transcript", b"badsig") + + assert await media_cache.get_file_tmdb(e.id) == ("keep-me", "movie") + assert [m for m in s.sent if m.get("type") == "error"] + finally: + await media_cache.close() |