|
|
@@ -125,6 +125,26 @@ class TestExternalFolderCreation:
|
|
|
assert ext_folder["external_readonly"] is True
|
|
|
|
|
|
|
|
|
+def find_folder_in_tree(folders: list, name: str) -> dict | None:
|
|
|
+ """Recursively search a folder tree for a folder by name."""
|
|
|
+ for f in folders:
|
|
|
+ if f["name"] == name:
|
|
|
+ return f
|
|
|
+ result = find_folder_in_tree(f.get("children", []), name)
|
|
|
+ if result:
|
|
|
+ return result
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+def collect_folder_names(folders: list) -> list[str]:
|
|
|
+ """Recursively collect all folder names from a tree."""
|
|
|
+ names = []
|
|
|
+ for f in folders:
|
|
|
+ names.append(f["name"])
|
|
|
+ names.extend(collect_folder_names(f.get("children", [])))
|
|
|
+ return names
|
|
|
+
|
|
|
+
|
|
|
class TestExternalFolderScan:
|
|
|
"""Tests for POST /library/folders/{id}/scan."""
|
|
|
|
|
|
@@ -158,22 +178,42 @@ class TestExternalFolderScan:
|
|
|
@pytest.mark.asyncio
|
|
|
@pytest.mark.integration
|
|
|
async def test_scan_discovers_files(self, async_client: AsyncClient, db_session, external_folder):
|
|
|
- """Verify scan discovers supported files."""
|
|
|
+ """Verify scan discovers supported files and creates subfolders."""
|
|
|
response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
|
|
|
assert response.status_code == 200
|
|
|
result = response.json()
|
|
|
- # Should find: benchy.3mf, bracket.stl, print.gcode, subfolder/nested.stl
|
|
|
+ # Should find: benchy.3mf, bracket.stl, print.gcode (root) + subfolder/nested.stl
|
|
|
# Should skip: readme.txt (unsupported), .hidden.3mf (hidden)
|
|
|
assert result["added"] == 4
|
|
|
assert result["removed"] == 0
|
|
|
|
|
|
+ # Root folder should have 3 files (nested.stl is in subfolder)
|
|
|
+ response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
|
|
|
+ root_files = response.json()
|
|
|
+ assert len(root_files) == 3
|
|
|
+ root_filenames = {f["filename"] for f in root_files}
|
|
|
+ assert root_filenames == {"benchy.3mf", "bracket.stl", "print.gcode"}
|
|
|
+
|
|
|
+ # Subfolder should exist in the tree and contain nested.stl
|
|
|
+ response = await async_client.get("/api/v1/library/folders")
|
|
|
+ folders = response.json()
|
|
|
+ subfolder = find_folder_in_tree(folders, "subfolder")
|
|
|
+ assert subfolder is not None
|
|
|
+ assert subfolder["is_external"] is True
|
|
|
+ assert subfolder["parent_id"] == external_folder["id"]
|
|
|
+
|
|
|
+ response = await async_client.get(f"/api/v1/library/files?folder_id={subfolder['id']}")
|
|
|
+ sub_files = response.json()
|
|
|
+ assert len(sub_files) == 1
|
|
|
+ assert sub_files[0]["filename"] == "nested.stl"
|
|
|
+
|
|
|
@pytest.mark.asyncio
|
|
|
@pytest.mark.integration
|
|
|
async def test_scan_skips_hidden_files(self, async_client: AsyncClient, db_session, external_folder):
|
|
|
"""Verify hidden files are skipped by default."""
|
|
|
await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
|
|
|
|
|
|
- # List files in folder
|
|
|
+ # List files in root folder
|
|
|
response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
|
|
|
assert response.status_code == 200
|
|
|
files = response.json()
|
|
|
@@ -240,15 +280,138 @@ class TestExternalFolderScan:
|
|
|
@pytest.mark.asyncio
|
|
|
@pytest.mark.integration
|
|
|
async def test_scan_files_marked_external(self, async_client: AsyncClient, db_session, external_folder):
|
|
|
- """Verify scanned files have is_external=True."""
|
|
|
+ """Verify scanned files have is_external=True in root and subfolders."""
|
|
|
await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
|
|
|
|
|
|
+ # Check root folder files
|
|
|
response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
|
|
|
files = response.json()
|
|
|
assert len(files) > 0
|
|
|
for f in files:
|
|
|
assert f["is_external"] is True
|
|
|
|
|
|
+ # Check subfolder files
|
|
|
+ response = await async_client.get("/api/v1/library/folders")
|
|
|
+ folders = response.json()
|
|
|
+ subfolder = find_folder_in_tree(folders, "subfolder")
|
|
|
+ assert subfolder is not None
|
|
|
+ response = await async_client.get(f"/api/v1/library/files?folder_id={subfolder['id']}")
|
|
|
+ sub_files = response.json()
|
|
|
+ for f in sub_files:
|
|
|
+ assert f["is_external"] is True
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_scan_creates_nested_subfolders(self, async_client: AsyncClient, db_session, external_dir):
|
|
|
+ """Verify deeply nested directories create correct folder hierarchy."""
|
|
|
+ # Create nested structure: deep/nested/dir/model.stl
|
|
|
+ deep = external_dir / "deep" / "nested" / "dir"
|
|
|
+ deep.mkdir(parents=True)
|
|
|
+ (deep / "model.stl").write_bytes(b"deepstl")
|
|
|
+
|
|
|
+ data = {
|
|
|
+ "name": "Nested Test",
|
|
|
+ "external_path": str(external_dir),
|
|
|
+ "readonly": True,
|
|
|
+ "show_hidden": False,
|
|
|
+ }
|
|
|
+ response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
|
+ root = response.json()
|
|
|
+
|
|
|
+ response = await async_client.post(f"/api/v1/library/folders/{root['id']}/scan")
|
|
|
+ assert response.status_code == 200
|
|
|
+
|
|
|
+ # Verify folder chain: root -> deep -> nested -> dir
|
|
|
+ response = await async_client.get("/api/v1/library/folders")
|
|
|
+ all_folders = response.json()
|
|
|
+
|
|
|
+ deep = find_folder_in_tree(all_folders, "deep")
|
|
|
+ assert deep is not None
|
|
|
+ assert deep["parent_id"] == root["id"]
|
|
|
+ assert deep["is_external"] is True
|
|
|
+
|
|
|
+ nested = find_folder_in_tree(all_folders, "nested")
|
|
|
+ assert nested is not None
|
|
|
+ assert nested["parent_id"] == deep["id"]
|
|
|
+
|
|
|
+ dir_folder = find_folder_in_tree(all_folders, "dir")
|
|
|
+ assert dir_folder is not None
|
|
|
+ assert dir_folder["parent_id"] == nested["id"]
|
|
|
+
|
|
|
+ # model.stl should be in the "dir" folder
|
|
|
+ response = await async_client.get(f"/api/v1/library/files?folder_id={dir_folder['id']}")
|
|
|
+ files = response.json()
|
|
|
+ assert len(files) == 1
|
|
|
+ assert files[0]["filename"] == "model.stl"
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_scan_skips_hidden_directories(self, async_client: AsyncClient, db_session, external_dir):
|
|
|
+ """Verify hidden directories are skipped when show_hidden=False."""
|
|
|
+ hidden_dir = external_dir / ".hidden_dir"
|
|
|
+ hidden_dir.mkdir()
|
|
|
+ (hidden_dir / "secret.stl").write_bytes(b"secret")
|
|
|
+
|
|
|
+ data = {
|
|
|
+ "name": "Hidden Dir Test",
|
|
|
+ "external_path": str(external_dir),
|
|
|
+ "readonly": True,
|
|
|
+ "show_hidden": False,
|
|
|
+ }
|
|
|
+ response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
|
+ root = response.json()
|
|
|
+
|
|
|
+ response = await async_client.post(f"/api/v1/library/folders/{root['id']}/scan")
|
|
|
+ result = response.json()
|
|
|
+ # Should find 4 files (root 3 + subfolder/nested.stl) but NOT .hidden_dir/secret.stl
|
|
|
+ assert result["added"] == 4
|
|
|
+
|
|
|
+ # No ".hidden_dir" folder should be created
|
|
|
+ response = await async_client.get("/api/v1/library/folders")
|
|
|
+ folder_names = collect_folder_names(response.json())
|
|
|
+ assert ".hidden_dir" not in folder_names
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_scan_removes_deleted_subfolder(
|
|
|
+ self, async_client: AsyncClient, db_session, external_folder, external_dir
|
|
|
+ ):
|
|
|
+ """Verify scan removes empty subfolder entries when directory deleted from disk."""
|
|
|
+ await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
|
|
|
+
|
|
|
+ # Verify subfolder exists
|
|
|
+ response = await async_client.get("/api/v1/library/folders")
|
|
|
+ subfolder = find_folder_in_tree(response.json(), "subfolder")
|
|
|
+ assert subfolder is not None
|
|
|
+
|
|
|
+ # Delete the subfolder from disk
|
|
|
+ import shutil
|
|
|
+
|
|
|
+ shutil.rmtree(external_dir / "subfolder")
|
|
|
+
|
|
|
+ # Re-scan
|
|
|
+ response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
|
|
|
+ result = response.json()
|
|
|
+ assert result["removed"] == 1 # nested.stl removed
|
|
|
+
|
|
|
+ # Subfolder should be cleaned up (empty + directory gone)
|
|
|
+ response = await async_client.get("/api/v1/library/folders")
|
|
|
+ subfolder = find_folder_in_tree(response.json(), "subfolder")
|
|
|
+ assert subfolder is None
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_scan_subfolder_inherits_readonly(
|
|
|
+ self, async_client: AsyncClient, db_session, external_folder, external_dir
|
|
|
+ ):
|
|
|
+ """Verify created subfolders inherit external_readonly from parent."""
|
|
|
+ await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
|
|
|
+
|
|
|
+ response = await async_client.get("/api/v1/library/folders")
|
|
|
+ subfolder = find_folder_in_tree(response.json(), "subfolder")
|
|
|
+ assert subfolder is not None
|
|
|
+ assert subfolder["external_readonly"] is True
|
|
|
+
|
|
|
|
|
|
class TestExternalFolderProtections:
|
|
|
"""Tests for read-only protections on external folders."""
|