aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-15 22:55:25 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-15 22:55:25 +0200
commit1f84c047914cf21df1a1de196d990d193539523d (patch)
treea818f18127af96349b6f3da8c53212c324672b60 /packages/meshbay-hub/tests
parent459fc93e98f23e326c2fa77fe86ba74c2bae77f0 (diff)
downloadmeshbay-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.py105
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py9
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):