diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-15 22:55:25 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-15 22:55:25 +0200 |
| commit | 1f84c047914cf21df1a1de196d990d193539523d (patch) | |
| tree | a818f18127af96349b6f3da8c53212c324672b60 /packages/meshbay-hub/tests | |
| parent | 459fc93e98f23e326c2fa77fe86ba74c2bae77f0 (diff) | |
| download | meshbay-1f84c047914cf21df1a1de196d990d193539523d.tar.gz | |
feat(hub): drop files and folders onto Files to upload them
Into the folder on screen, under the Upload button's rule. A name already
there, or one the node would refuse, cancels the whole drop with a message.
Folders are recreated level by level; files go out a few at a time. The
in-flight upload guard is keyed by folder and name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/test_files_drop_upload.py | 105 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_transport_contracts.py | 9 |
2 files changed, 111 insertions, 3 deletions
diff --git a/packages/meshbay-hub/tests/test_files_drop_upload.py b/packages/meshbay-hub/tests/test_files_drop_upload.py new file mode 100644 index 0000000..5dfaf23 --- /dev/null +++ b/packages/meshbay-hub/tests/test_files_drop_upload.py @@ -0,0 +1,105 @@ +""" +Dropping files and folders onto Files (`files-app.js`). + +What a drop does is decided before anything is sent, and both decisions are +tested here by running the real functions out of the source: + + * a name already in the folder refuses the whole drop — left to the node, a + colliding file is quietly stored as "x (1).jpg" and a colliding folder is + refused after the ones before it were made; + * a name the node would refuse refuses it too, which only works while the + client's copy of the node's filename rule gives the node's answers. That is + a second copy of a rule, so it is checked against the first one here. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +FILES_APP = STATIC / "files-app.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not FILES_APP.exists(), + reason="node or the SPA sources are not available") + + +@pytest.fixture(scope="module") +def source(): + text = FILES_APP.read_text(encoding="utf-8") + const = re.search(r"^const UPLOAD_NAME = .*;$", text, re.M) + funcs = [re.search(rf"^function {name}\(.*?^\}}", text, re.M | re.S) + for name in ("namesIn", "planDrop")] + assert const and all(funcs), "files-app.js no longer has what this test reads" + return "\n".join([const.group(0)] + [m.group(0) for m in funcs]) + + +def _run(tmp_path, source, expr): + script = tmp_path / "case.js" + script.write_text(f"{source}\nconsole.log(JSON.stringify({expr}));", encoding="utf-8") + out = subprocess.run(["node", str(script)], capture_output=True, text=True, check=True) + return json.loads(out.stdout) + + +NAMES = [ + "photo.jpg", "Été 2024", "東京", "track (1).flac", "[live] set", "a_b-c", + "rock & roll", "l’été", "x+y#z@w", "9lives", + ".DS_Store", "_private", "-dash", " space", "trailing ", "trailing.", + "a/b", "a\\b", "semi;colon", "x" * 128, "x" * 129, "", "tab\tname", +] + + +def test_the_client_refuses_exactly_the_names_the_node_refuses(tmp_path, source): + roots = pytest.importorskip("meshbay_node.roots") + node = [bool(roots.SAFE_UPLOAD_NAME.match(n)) for n in NAMES] + client = _run(tmp_path, source, f"{json.dumps(NAMES)}.map((n) => UPLOAD_NAME.test(n))") + differ = [n for n, a, b in zip(NAMES, node, client) if a != b] + assert not differ, f"client and node disagree on: {differ}" + + +def test_names_in_a_folder_count_files_folders_and_empty_folders(tmp_path, source): + entries = [{"path": "music", "name": "a.mp3"}, + {"path": "music/Album", "name": "t.flac"}, + {"path": "musicals", "name": "not-here.txt"}] + got = _run(tmp_path, source, + f"namesIn({json.dumps(entries)}, ['music/Empty', 'music/Album/cd1'], 'music').sort()") + assert got == ["Album", "Empty", "a.mp3"] + + +def _plan(tmp_path, source, items, existing): + return _run(tmp_path, source, f"planDrop({json.dumps(items)}, {json.dumps(existing)})") + + +def test_a_name_already_there_is_a_conflict_whatever_its_case(tmp_path, source): + items = [{"kind": "file", "path": "Photo.JPG"}, + {"kind": "dir", "path": "Album"}, {"kind": "file", "path": "Album/x.jpg"}, + {"kind": "file", "path": "new.txt"}] + plan = _plan(tmp_path, source, items, ["photo.jpg", "album", "other"]) + assert plan["conflicts"] == ["Album", "Photo.JPG"] + + +def test_only_the_top_level_can_conflict(tmp_path, source): + items = [{"kind": "file", "path": "Album/photo.jpg"}] + plan = _plan(tmp_path, source, items, ["photo.jpg"]) + assert plan["conflicts"] == [] + + +def test_a_name_the_node_would_refuse_is_reported_wherever_it_is(tmp_path, source): + items = [{"kind": "dir", "path": "Album"}, + {"kind": "file", "path": "Album/.DS_Store"}, + {"kind": "file", "path": "Album/ok.jpg"}] + plan = _plan(tmp_path, source, items, []) + assert plan["invalid"] == [".DS_Store"] + + +def test_folders_are_created_parents_first_including_implied_ones(tmp_path, source): + items = [{"kind": "file", "path": "A/B/C/deep.txt"}, + {"kind": "dir", "path": "A/Empty"}, + {"kind": "file", "path": "top.txt"}] + plan = _plan(tmp_path, source, items, []) + assert plan["dirs"] == ["A", "A/B", "A/Empty", "A/B/C"] + assert [f["path"] for f in plan["files"]] == ["A/B/C/deep.txt", "top.txt"] diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index d13cfd4..c10f183 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -332,9 +332,12 @@ def test_uploads_are_tracked_per_upload(transport): """Acks interleave when two files are in flight.""" assert "this._uploaders = new Map()" in transport assert "this._uploaders.set(uploadId" in transport - # And the "already being uploaded" guard still speaks in filenames, because - # that is what the caller passed and what it would recognise in the error. - assert "this._inFlightUploads.has(file.name)" in transport + # The "already being uploaded" guard is keyed by folder and name, as the + # node's own upload state is: a dropped folder can hold two files of one name. + assert "const inFlightKey = `${dir || ''}/${file.name}`;" in transport + assert "this._inFlightUploads.has(inFlightKey)" in transport + # And its error still speaks in the filename the caller would recognise. + assert "`${file.name} is already being uploaded`" in transport def test_the_upload_itself_is_sealed(transport): |