diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-24 14:33:20 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-24 14:33:20 +0200 |
| commit | 0b0da86f1f9d6f0b1a27b5e1e1658c42de9f356a (patch) | |
| tree | ba8daf5dcf050d44b7b0e766babbfda8fadac59f /packages/meshbay-node/tests | |
| parent | 6af05abf410bbd038ce7fa6915a659defc509071 (diff) | |
| download | meshbay-0b0da86f1f9d6f0b1a27b5e1e1658c42de9f356a.tar.gz | |
feat(node,hub): season-specific overviews, manual TMDB match correction, and wizard polish
Two operator-facing fixes for a real 3-season show whose automatic TMDB
match was wrong at the show level: per-season overview/air_date tabs in the
detail modal (falling back to the show-level text when a season's own is
empty), and a "Fix match…" search-and-correct affordance that re-resolves
every file sharing the corrected show's display_title. New signed op
OP_TMDB_OVERRIDE and two read-only pairs (season_meta_req/resp,
tmdb_search_req/resp), MNP_VERSION 0.5 -> 0.6.
Also: the create-group wizard gets a spinning indexing indicator and an
app-selection step, group settings default the TMDB language to the
operator's own locale (never as a global default), and a file renamed
mid-session now re-triggers title parsing instead of being silently
skipped by the enrichment dedup guard.
Fixes two bugs found during this work: the search overlay's z-index lost
to the base video-overlay class and rendered invisibly, and season_meta's
own empty overview didn't fall back to the show-level one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY
Diffstat (limited to 'packages/meshbay-node/tests')
4 files changed, 598 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_rename_reenrichment.py b/packages/meshbay-node/tests/test_rename_reenrichment.py new file mode 100644 index 0000000..87e0d1a --- /dev/null +++ b/packages/meshbay-node/tests/test_rename_reenrichment.py @@ -0,0 +1,163 @@ +""" +Bug found live, 2026-08-24: an episode file first named in French (its +release folder mixed languages across seasons) was renamed by the operator +to match its English-named siblings — but kept showing as its own +separate poster-grid card, and its own row in Flat list, indefinitely. + +`_enriched_attempted` (daemon.py) exists so that enrichment's own +field-fill (duration/thumb_hash/display_title/... landing back via +`_on_enriched`) does not re-trigger itself forever — but it also silently +blocked the *new* filename from ever being title-parsed at all, since the +file's content (and so its id) is unchanged by a rename. A rename/move is +exactly the case `_reenrich_renamed_video_entries` exists to detect: same +id, but `name` or `path` differs from the version last broadcast. +""" + +import asyncio + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_common.crypto import generate_gek +from meshbay_node.config import Config, HubConfig, NodeConfig, GroupConfig, KeystoreConfig +from meshbay_node.daemon import NodeDaemon +from meshbay_node.indexer import DirectoryIndexer + +from conftest import one_root + +pytestmark = pytest.mark.asyncio + + +def _free_port() -> int: + import socket + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +class _StubRoster: + async def video_root(self, group_id): + return "shared" + + +class _SpyEnricher: + """Records which file ids were actually (re-)scheduled, without + needing a real ffmpeg/ffprobe pipeline for this test.""" + + def __init__(self): + self.spawned = [] + + def spawn(self, entry, file_path, on_done): + self.spawned.append(entry.id) + + async def _noop(): + return None + + return asyncio.ensure_future(_noop()) + + +async def test_a_renamed_file_gets_re_enriched(tmp_path): + group_id = "a" * 32 + shared = tmp_path / "shared" + shared.mkdir() + old_path = shared / "la-guerre-des-mondes-s03e02.mkv" + old_path.write_bytes(b"not a real video, just needs to be indexed as one") + + config = Config( + hub=HubConfig(url="http://localhost:9999", username="testuser"), + node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()), + groups=[GroupConfig( + id=group_id, name="test-group", shared_dir=str(shared), + visibility="private", quic_port=29016, + )], + keystore=KeystoreConfig(path=tmp_path / "keystore.enc"), + data_dir=tmp_path / "data", + ) + daemon = NodeDaemon(config) + daemon._broadcast_coalesce_secs = 0.01 + daemon._enricher = _SpyEnricher() + daemon._roster = _StubRoster() + + indexer = DirectoryIndexer( + roots=one_root(shared), group_id=group_id, + sk_node=Ed25519PrivateKey.generate(), gek=generate_gek(), + # reconcile() below calls this itself — the initial scan doesn't + # (see test_startup_scan_enrichment.py), so that first broadcast is + # still triggered manually, matching _bg_scan's real sequence. + on_change=daemon._on_index_change) + await indexer.initial_scan() + await daemon._on_index_change(indexer) + await asyncio.sleep(0.05) + + entry = next(iter(indexer.index.entries)) + file_id = entry.id + assert daemon._enricher.spawned == [file_id], ( + "the file must be scheduled for enrichment once, under its original name") + + new_path = shared / "war-of-the-worlds-s03e02.mkv" + old_path.rename(new_path) + changed = await indexer.reconcile() + assert changed, "the rename must actually be picked up by reconcile()" + # The re-broadcast is a fire-and-forget task chained behind the + # coalescing timer, itself scheduling another fire-and-forget task — + # poll rather than guess a single sleep long enough for both hops. + for _ in range(30): + if len(daemon._enricher.spawned) >= 2: + break + await asyncio.sleep(0.02) + + renamed_entry = indexer.index.get_entry(file_id) + assert renamed_entry is not None + assert renamed_entry.name == "war-of-the-worlds-s03e02.mkv" + assert daemon._enricher.spawned == [file_id, file_id], ( + "a rename must re-schedule enrichment for the same file id — " + "_enriched_attempted must not permanently block the new filename " + "from ever being title-parsed") + + +async def test_an_unrelated_update_does_not_re_trigger_enrichment(tmp_path): + """ + The other half of the same fix: an update whose name/path did *not* + change (the ordinary case — enrichment's own field-fill, or the + reconcile sweep confirming a file unmodified) must not re-schedule + enrichment. Without this, `_on_enriched` merging a file's own results + back into the index would count as its own trigger and loop forever. + """ + group_id = "a" * 32 + shared = tmp_path / "shared" + shared.mkdir() + path = shared / "movie.mkv" + path.write_bytes(b"not a real video, just needs to be indexed as one") + + config = Config( + hub=HubConfig(url="http://localhost:9999", username="testuser"), + node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()), + groups=[GroupConfig( + id=group_id, name="test-group", shared_dir=str(shared), + visibility="private", quic_port=29017, + )], + keystore=KeystoreConfig(path=tmp_path / "keystore.enc"), + data_dir=tmp_path / "data", + ) + daemon = NodeDaemon(config) + daemon._broadcast_coalesce_secs = 0.01 + daemon._enricher = _SpyEnricher() + daemon._roster = _StubRoster() + + indexer = DirectoryIndexer( + roots=one_root(shared), group_id=group_id, + sk_node=Ed25519PrivateKey.generate(), gek=generate_gek(), + on_change=daemon._on_index_change) + await indexer.initial_scan() + await daemon._on_index_change(indexer) + await asyncio.sleep(0.05) + + entry = next(iter(indexer.index.entries)) + assert daemon._enricher.spawned == [entry.id] + + # A reconcile pass that finds nothing changed at all — not even a + # rename — must not re-schedule anything. + changed = await indexer.reconcile() + await asyncio.sleep(0.05) + assert not changed + assert daemon._enricher.spawned == [entry.id] diff --git a/packages/meshbay-node/tests/test_season_and_search_requests.py b/packages/meshbay-node/tests/test_season_and_search_requests.py new file mode 100644 index 0000000..8ba55fb --- /dev/null +++ b/packages/meshbay-node/tests/test_season_and_search_requests.py @@ -0,0 +1,187 @@ +""" +`_do_season_meta_request` (per-season TMDB overview/poster/air_date, for the +season-tab view — docs/mediacenter.md §5.4's fix for a 3-season show whose +overview read as season-3-specific for every season) and +`_do_tmdb_search_request` (raw TMDB candidates for an operator correcting a +wrong automatic match). Neither is a signed admin op — see each handler's own +docstring for why — so these tests only exercise the read path, unlike +test_tmdb_override_policy.py. +""" + +import pytest + +from meshbay_common.protocol import MNP +from meshbay_node.media_cache import MediaCache +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +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} + session.sent = [] + session._send = session.sent.append + return session + + +@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() + + +class FakeTmdbClient: + def __init__(self, season_json=None): + self.season_json = season_json + self.tv_season_calls = [] + self.movie_search_calls = [] + self.tv_search_calls = [] + + @staticmethod + def poster_url(path): + return f"https://image.tmdb.org/t/p/w500{path}" + + async def fetch_image(self, url): + return b"jpeg-bytes-for-" + url.encode() + + async def tv_season(self, tmdb_id, season, language=None): + self.tv_season_calls.append((tmdb_id, season, language)) + return self.season_json + + async def search_movie_results(self, title): + self.movie_search_calls.append(title) + return [{"id": 111, "title": title, "release_date": "2019-05-01", "poster_path": "/m.jpg"}] + + async def search_tv_results(self, title): + self.tv_search_calls.append(title) + return [{"id": 222, "name": title, "first_air_date": "2021-03-01", "poster_path": "/t.jpg"}] + + +# ── season_meta_req ────────────────────────────────────────────────────────── + +async def test_season_meta_missing_tmdb_id_is_refused(): + session = _session() + await session._do_season_meta_request({"season": 1}) + assert session.sent == [{"type": "error", "detail": "Missing tmdb_id or season"}] + + +async def test_season_meta_non_int_season_is_refused(): + session = _session() + await session._do_season_meta_request({"tmdb_id": "42", "season": "1"}) + assert session.sent == [{"type": "error", "detail": "Missing tmdb_id or season"}] + + +async def test_season_meta_with_no_cache_or_client_reports_zero_confidence(): + session = _session(media_cache=None, tmdb_client=None) + await session._do_season_meta_request({"tmdb_id": "42", "season": 1}) + assert session.sent == [{ + "type": MNP.SEASON_META_RESP, "v": session.sent[0]["v"], + "tmdb_id": "42", "season": 1, "confidence": 0, + }] + + +async def test_season_meta_cache_hit_skips_the_tmdb_call(media_cache): + await media_cache.set_season_meta("42", 3, { + "name": "Season 3", "overview": "cached overview", "air_date": "2023-01-01", + "poster_path": "/cached.jpg", + }) + client = FakeTmdbClient() + session = _session(media_cache=media_cache, tmdb_client=client) + + await session._do_season_meta_request({"tmdb_id": "42", "season": 3}) + + assert client.tv_season_calls == [], "a cached season must not be re-fetched" + resp = session.sent[0] + assert resp["type"] == MNP.SEASON_META_RESP + assert resp["confidence"] == 1.0 + assert resp["overview"] == "cached overview" + + +async def test_season_meta_cache_miss_fetches_and_caches(media_cache): + client = FakeTmdbClient(season_json={ + "name": "Season 1", "overview": "fresh overview", "air_date": "2020-01-01", + "poster_path": "/fresh.jpg", + }) + session = _session(media_cache=media_cache, tmdb_client=client) + + await session._do_season_meta_request({"tmdb_id": "7", "season": 1}) + + assert client.tv_season_calls == [("7", 1, None)] + resp = session.sent[0] + assert resp["overview"] == "fresh overview" + assert resp["poster_thumb_hash"] is not None + cached = await media_cache.get_season_meta("7", 1) + assert cached["overview"] == "fresh overview", "a fetched season must be cached for next time" + + +async def test_season_meta_empty_overview_falls_back_to_english(media_cache): + async def tv_season(tmdb_id, season, language=None): + if language == "en-US": + return {"name": "S1", "overview": "English overview", "air_date": "2020-01-01", + "poster_path": "/p.jpg"} + return {"name": "S1", "overview": "", "air_date": "2020-01-01", "poster_path": "/p.jpg"} + + client = FakeTmdbClient() + client.tv_season = tv_season + session = _session(media_cache=media_cache, tmdb_client=client) + + await session._do_season_meta_request({"tmdb_id": "9", "season": 1}) + + assert session.sent[0]["overview"] == "English overview" + + +# ── tmdb_search_req ────────────────────────────────────────────────────────── + +async def test_search_missing_query_is_refused(): + session = _session() + await session._do_tmdb_search_request({"media_type": "movie"}) + assert session.sent == [{"type": "error", "detail": "Missing query or media_type"}] + + +async def test_search_bad_media_type_is_refused(): + session = _session() + await session._do_tmdb_search_request({"query": "war", "media_type": "album"}) + assert session.sent == [{"type": "error", "detail": "Missing query or media_type"}] + + +async def test_search_with_no_cache_or_client_returns_empty_results(): + session = _session(media_cache=None, tmdb_client=None) + await session._do_tmdb_search_request({"query": "war", "media_type": "tv"}) + assert session.sent == [{ + "type": MNP.TMDB_SEARCH_RESP, "v": session.sent[0]["v"], + "query": "war", "media_type": "tv", "results": [], + }] + + +async def test_search_movie_calls_movie_search_and_echoes_media_type(media_cache): + client = FakeTmdbClient() + session = _session(media_cache=media_cache, tmdb_client=client) + + await session._do_tmdb_search_request({"query": "War of the Worlds", "media_type": "movie"}) + + assert client.movie_search_calls == ["War of the Worlds"] + assert client.tv_search_calls == [] + resp = session.sent[0] + assert resp["type"] == MNP.TMDB_SEARCH_RESP + assert resp["media_type"] == "movie", ( + "media_type must be echoed back — otherwise a movie search and a tv " + "search for the same query are indistinguishable to the client's " + "keyed response matching (transport.js tmdb_search_resp handler)") + assert resp["results"] == [{ + "tmdb_id": "111", "title": "War of the Worlds", "year": "2019", + "poster_thumb_hash": resp["results"][0]["poster_thumb_hash"], + }] + + +async def test_search_tv_calls_tv_search(media_cache): + client = FakeTmdbClient() + session = _session(media_cache=media_cache, tmdb_client=client) + + await session._do_tmdb_search_request({"query": "War of the Worlds", "media_type": "tv"}) + + assert client.tv_search_calls == ["War of the Worlds"] + assert client.movie_search_calls == [] + assert session.sent[0]["media_type"] == "tv" diff --git a/packages/meshbay-node/tests/test_tmdb_override_policy.py b/packages/meshbay-node/tests/test_tmdb_override_policy.py new file mode 100644 index 0000000..b8fa6f9 --- /dev/null +++ b/packages/meshbay-node/tests/test_tmdb_override_policy.py @@ -0,0 +1,172 @@ +""" +An operator correcting a wrong automatic TMDB match (found live: a real +show's search consistently matched a season-3-specific promotional TMDB +entry instead of the show itself). Signed like video_root/tmdb_config — +it changes what every member sees, node-wide (media_cache is shared, not +per-viewer) — and, once authorized, applies to every index entry sharing +the representative file's display_title, the same grouping the poster +grid itself uses (§3.4/§V6), not just the one file the operator happened +to be looking at. +""" + +import hashlib +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_common.adminop import OP_TMDB_OVERRIDE +from meshbay_common.protocol import IndexEntry, MNP +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.media_cache import MediaCache +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +from conftest import one_root + +pytestmark = pytest.mark.asyncio + + +def _session(tmp_path: Path, user_id: str, *, operator: str | None = None) -> WebRTCPeerSession: + shared_root = tmp_path / "shared" + shared_root.mkdir(exist_ok=True) + index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) + ctx = { + "roots": one_root(shared_root), + "index": index, + "sk_node": index.sk_node, + "node_user_id": operator, + } + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + session._ctx = ctx + session._group_id = None + session._user_id = user_id + session._pk_user = "" + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + return session + + +def _entry(path: str, name: str, display_title: str) -> IndexEntry: + # A real id is a blake3 content hash; sha256 here is just a stand-in with + # the same property that matters for these tests — deterministic and + # effectively collision-free across the handful of entries a test builds. + # (`hash((path, name)) % 10` was tried here before and is NOT that: it's + # randomized per-process by PYTHONHASHSEED and collides constantly across + # only 10 possible values, silently dropping entries in GroupIndex's + # id-keyed dict.) + digest = hashlib.sha256(f"{path}/{name}".encode()).hexdigest() + return IndexEntry( + id=digest, name=name, path=path, + size=1, type="video", added_at=0, display_title=display_title, + season=1, episode=1, + ) + + +# ── Refused before a challenge is even issued ─────────────────────────────── + +async def test_missing_path_is_refused(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_tmdb_override({"tmdb_id": "123", "media_type": "tv"}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_missing_tmdb_id_is_refused(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._ctx["index"].add_entry(_entry("shared", "ep.mkv", "Show")) + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_tmdb_override({"path": "shared", "media_type": "tv"}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_unknown_path_is_refused(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_tmdb_override({"path": "nope", "tmdb_id": "123", "media_type": "tv"}) + + assert not issued + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path): + session = _session(tmp_path, "member-1", operator="the-operator") + session._ctx["index"].add_entry(_entry("shared", "ep.mkv", "Show")) + session._has_admin_authority = lambda: False + + session._do_tmdb_override({"path": "shared", "tmdb_id": "123", "media_type": "tv"}) + + assert [m for m in session.sent if m.get("type") == "error"] + + +async def test_a_valid_request_is_signed(tmp_path): + session = _session(tmp_path, "op", operator="op") + session._ctx["index"].add_entry(_entry("shared", "ep.mkv", "War of the Worlds")) + session._has_admin_authority = lambda: True + issued = [] + session._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + session._do_tmdb_override({"path": "shared", "tmdb_id": "2255", "media_type": "tv"}) + + assert issued == [(OP_TMDB_OVERRIDE, + "path=shared,tmdb_id=2255,media_type=tv")] + + +# ── Applying the override ─────────────────────────────────────────────────── + +async def test_override_updates_every_entry_sharing_the_display_title(tmp_path): + session = _session(tmp_path, "op", operator="op") + index = session._ctx["index"] + s1 = _entry("shared/S1", "s01e01.mkv", "War of the Worlds") + s2 = _entry("shared/S2", "s02e01.mkv", "War of the Worlds") + s3 = _entry("shared/S3", "s03e02.mkv", "War of the Worlds") + other_show = _entry("shared/Other", "ep.mkv", "A Different Show") + for e in (s1, s2, s3, other_show): + index.add_entry(e) + + media_cache = MediaCache(db_path=tmp_path / "media_cache.db") + await media_cache.open() + try: + session._ctx["media_cache"] = media_cache + # Signature verification itself is exercised generically elsewhere + # (test_roster_pairing.py) — this test is about the policy once a + # signature is known good: which entries actually get updated, and + # who is told about it. + session._verify_admin_sig = lambda transcript, sig: _true() + peer = type("Peer", (), {"sent": []})() + peer._send = peer.sent.append + session._peer_registry = lambda: {"peer-1": peer} + + await session._admin_exec_tmdb_override( + {"subject": "path=shared/S1,tmdb_id=999,media_type=tv"}, + b"transcript", b"sig") + + for e in (s1, s2, s3): + assert await media_cache.get_file_tmdb(e.id) == ("999", "tv"), ( + "every entry sharing the representative file's display_title " + "must be corrected, not just the one the operator clicked on") + assert await media_cache.get_file_tmdb(other_show.id) is None, ( + "a different show's own match must be left alone") + # The ack is broadcast to other connected peers, never echoed onto + # the requester's own `sent` — see the loop in + # _admin_exec_tmdb_override, which sends via each peer's own _send. + assert [m for m in peer.sent if m.get("type") == MNP.TMDB_OVERRIDE_ACK] + finally: + await media_cache.close() + + +async def _true(): + return True diff --git a/packages/meshbay-node/tests/test_wizard_apps_endpoint.py b/packages/meshbay-node/tests/test_wizard_apps_endpoint.py new file mode 100644 index 0000000..10ab489 --- /dev/null +++ b/packages/meshbay-node/tests/test_wizard_apps_endpoint.py @@ -0,0 +1,76 @@ +""" +Create Group wizard: choosing which apps a brand-new group offers, before +the (potentially long) initial scan — see app.js's CreateGroupWizard. This +is a loopback-only, operator-authenticated endpoint (11.5.3), same shape as +the existing member-upload one: a thin adapter over `ops.set_enabled_apps`, +with only the validation `_do_apps_enabled` (the signed MNP front door) +already does client-side in the wizard, but worth enforcing at this front +door too since nothing else would. +""" + +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from fastapi.testclient import TestClient + +from conftest import one_root +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.roster import Roster +from meshbay_node.ui.app import create_ui_app + +pytestmark = pytest.mark.asyncio + + +async def _client(tmp_path: Path): + shared = tmp_path / "shared" + shared.mkdir(exist_ok=True) + index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) + roster = Roster(db_path=tmp_path / "roster.db") + await roster.open() + state = { + "status": "running", + "groups_ctx": {"g" * 32: {"index": index, "roots": one_root(shared)}}, + "indexes": {"g" * 32: index}, + "roster": roster, + } + app = create_ui_app(state) + return TestClient(app), roster + + +async def test_narrowing_the_apps_persists_to_the_roster(tmp_path): + client, roster = await _client(tmp_path) + try: + resp = client.put(f"/api/groups/{'g' * 32}/apps", json={"apps": ["files", "video"]}) + assert resp.status_code == 200, resp.text + assert sorted(resp.json()["apps"]) == ["files", "video"] + assert sorted(await roster.enabled_apps("g" * 32)) == ["files", "video"] + finally: + await roster.close() + + +async def test_empty_apps_list_is_refused(tmp_path): + client, roster = await _client(tmp_path) + try: + resp = client.put(f"/api/groups/{'g' * 32}/apps", json={"apps": []}) + assert resp.status_code == 400 + finally: + await roster.close() + + +async def test_missing_apps_field_is_refused(tmp_path): + client, roster = await _client(tmp_path) + try: + resp = client.put(f"/api/groups/{'g' * 32}/apps", json={}) + assert resp.status_code == 400 + finally: + await roster.close() + + +async def test_unhosted_group_is_refused(tmp_path): + client, roster = await _client(tmp_path) + try: + resp = client.put("/api/groups/" + "z" * 32 + "/apps", json={"apps": ["files"]}) + assert resp.status_code == 404 + finally: + await roster.close() |