From 3d5a168ee592c28c696e445c623c9f8d2996b715 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 7 Oct 2026 12:48:34 +0200 Subject: fix(node): drop a directory moved out of the root from the index Watchdog reports such a move as one "directory deleted" event and nothing for the files, which the indexer ignored until the next reconcile. The freeze rules still apply: root live, directory gone, parent present. Co-Authored-By: Claude Opus 5.5 --- .../meshbay-node/tests/test_root_availability.py | 97 ++++++++++++++++++++++ 1 file changed, 97 insertions(+) (limited to 'packages/meshbay-node/tests') diff --git a/packages/meshbay-node/tests/test_root_availability.py b/packages/meshbay-node/tests/test_root_availability.py index 82bf6bd..e92ec95 100644 --- a/packages/meshbay-node/tests/test_root_availability.py +++ b/packages/meshbay-node/tests/test_root_availability.py @@ -13,6 +13,7 @@ an indexer that treats a vanished root as a set of deletions, which is what the straightforward implementation does. """ +import asyncio import os from pathlib import Path @@ -305,3 +306,99 @@ async def test_deleting_one_copy_keeps_the_other_listed(tmp_path): assert len(idx.index.entries) == 1, "the surviving copy was delisted" assert idx.index.entries[0].path == survivor + + +# ── A directory that leaves the root ───────────────────────────────────────── +# +# Moving a folder out of the root arrives from the watcher as one "directory +# deleted" event, with nothing for the files it held. Found live: a season moved +# out of a shared folder stayed listed, every episode "File not on disk", until +# the reconcile backstop came round. These are the same freeze rules as above, +# applied to that event. + +def _season(root: Path, name: str, *episodes: str) -> Path: + d = root / "Show" / name + d.mkdir(parents=True) + for ep in episodes: + (d / ep).write_bytes(ep.encode()) + return d + + +async def test_a_directory_moved_out_of_a_live_root_is_removed(tmp_path): + shows = tmp_path / "Shows" + low = _season(shows, "S3.LQ", "e1.mp4", "e2.mp4") + _season(shows, "S3", "e1.hd.mkv") + idx = await _indexer(_roots(shows)) + + low.rename(tmp_path / "S3.LQ") + await idx._update_entry(low, deleted=True, directory=True) + + # S3 shares a prefix with S3.LQ and must survive it. + assert _names(idx) == {"e1.hd.mkv"} + + +async def test_a_directory_event_from_a_vanished_root_freezes(tmp_path): + shows = tmp_path / "Shows" + season = _season(shows, "S1", "e1.mp4", "e2.mp4") + idx = await _indexer(_roots(shows)) + + os.rename(shows, tmp_path / "elsewhere") + await idx._update_entry(season, deleted=True, directory=True) + + assert _names(idx) == {"e1.mp4", "e2.mp4"} + assert idx.roots.roots[0].available is False + + +async def test_a_directory_whose_parent_is_gone_waits_for_the_parent(tmp_path): + """The parent going too is what a volume vanishing looks like below the + root. The parent's own event, when it comes, is the one that acts.""" + shows = tmp_path / "Shows" + season = _season(shows, "S1", "e1.mp4") + _season(shows, "S2", "e2.mp4") + idx = await _indexer(_roots(shows)) + + os.rename(shows / "Show", tmp_path / "Show") + await idx._update_entry(season, deleted=True, directory=True) + assert _names(idx) == {"e1.mp4", "e2.mp4"} + + await idx._update_entry(shows / "Show", deleted=True, directory=True) + assert _names(idx) == set() + + +async def test_a_directory_that_is_back_is_left_alone(tmp_path): + shows = tmp_path / "Shows" + season = _season(shows, "S1", "e1.mp4") + idx = await _indexer(_roots(shows)) + + # Moved out and back within the debounce: the event is stale. + await idx._update_entry(season, deleted=True, directory=True) + assert _names(idx) == {"e1.mp4"} + + +async def test_the_root_itself_is_never_removed_as_a_directory(tmp_path): + shows = tmp_path / "Shows" + _season(shows, "S1", "e1.mp4") + idx = await _indexer(_roots(shows)) + + await idx._update_entry(shows, deleted=True, directory=True) + assert _names(idx) == {"e1.mp4"} + + +async def test_the_watcher_reports_a_directory_moved_out(tmp_path): + """End to end, with the real observer: the event is the one watchdog emits.""" + shows = tmp_path / "Shows" + low = _season(shows, "S3.LQ", "e1.mp4") + _season(shows, "S3", "e1.hd.mkv") + idx = DirectoryIndexer(roots=_roots(shows), group_id="g" * 32, + sk_node=Ed25519PrivateKey.generate(), gek=None, + debounce_secs=0.05) + await idx.start() + try: + low.rename(tmp_path / "S3.LQ") + for _ in range(100): + if _names(idx) == {"e1.hd.mkv"}: + break + await asyncio.sleep(0.05) + assert _names(idx) == {"e1.hd.mkv"} + finally: + await idx.stop() -- cgit v1.2.3