mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
379 lines
15 KiB
Python
379 lines
15 KiB
Python
"""Integration tests for External Folder API endpoints."""
|
|
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
|
|
class TestExternalFolderCreation:
|
|
"""Tests for POST /library/folders/external."""
|
|
|
|
@pytest.fixture
|
|
def external_dir(self, tmp_path):
|
|
"""Create a temporary directory to act as an external folder."""
|
|
ext_dir = tmp_path / "nas_share"
|
|
ext_dir.mkdir()
|
|
# Add some test files
|
|
(ext_dir / "benchy.3mf").write_bytes(b"fake3mf")
|
|
(ext_dir / "bracket.stl").write_bytes(b"fakestl")
|
|
(ext_dir / "print.gcode").write_text("G28\nG1 X10 Y10")
|
|
(ext_dir / "readme.txt").write_text("not a print file")
|
|
(ext_dir / ".hidden.3mf").write_bytes(b"hidden")
|
|
return ext_dir
|
|
|
|
@pytest.fixture
|
|
def nested_external_dir(self, external_dir):
|
|
"""Create a nested subdirectory in the external folder."""
|
|
sub = external_dir / "subfolder"
|
|
sub.mkdir()
|
|
(sub / "nested_part.stl").write_bytes(b"nestedstl")
|
|
return external_dir
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_create_external_folder(self, async_client: AsyncClient, db_session, external_dir):
|
|
"""Verify external folder can be created with valid path."""
|
|
data = {
|
|
"name": "NAS Prints",
|
|
"external_path": str(external_dir),
|
|
"readonly": True,
|
|
"show_hidden": False,
|
|
}
|
|
response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
assert result["name"] == "NAS Prints"
|
|
assert result["is_external"] is True
|
|
assert result["external_readonly"] is True
|
|
assert result["external_show_hidden"] is False
|
|
assert result["external_path"] == str(external_dir.resolve())
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_create_external_folder_nonexistent_path(self, async_client: AsyncClient, db_session):
|
|
"""Verify 400 for non-existent path."""
|
|
data = {
|
|
"name": "Bad Path",
|
|
"external_path": "/nonexistent/path/that/does/not/exist",
|
|
}
|
|
response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
assert response.status_code == 400
|
|
assert "does not exist" in response.json()["detail"]
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_create_external_folder_system_dir_blocked(self, async_client: AsyncClient, db_session):
|
|
"""Verify system directories are blocked."""
|
|
data = {
|
|
"name": "System",
|
|
"external_path": "/proc",
|
|
}
|
|
response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
assert response.status_code == 400
|
|
assert "system directory" in response.json()["detail"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_create_external_folder_file_not_dir(self, async_client: AsyncClient, db_session, tmp_path):
|
|
"""Verify 400 when path is a file, not directory."""
|
|
file_path = tmp_path / "not_a_dir.txt"
|
|
file_path.write_text("hello")
|
|
data = {
|
|
"name": "Not A Dir",
|
|
"external_path": str(file_path),
|
|
}
|
|
response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
assert response.status_code == 400
|
|
assert "not a directory" in response.json()["detail"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_create_external_folder_duplicate_path(self, async_client: AsyncClient, db_session, external_dir):
|
|
"""Verify 409 when same path already linked."""
|
|
data = {
|
|
"name": "First",
|
|
"external_path": str(external_dir),
|
|
}
|
|
response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
assert response.status_code == 200
|
|
|
|
data["name"] = "Duplicate"
|
|
response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
assert response.status_code == 409
|
|
assert "already exists" in response.json()["detail"]
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_external_folder_appears_in_tree(self, async_client: AsyncClient, db_session, external_dir):
|
|
"""Verify external folder shows up in folder tree with external fields."""
|
|
data = {
|
|
"name": "My NAS",
|
|
"external_path": str(external_dir),
|
|
"readonly": True,
|
|
}
|
|
await async_client.post("/api/v1/library/folders/external", json=data)
|
|
|
|
response = await async_client.get("/api/v1/library/folders")
|
|
assert response.status_code == 200
|
|
folders = response.json()
|
|
ext_folder = next((f for f in folders if f["name"] == "My NAS"), None)
|
|
assert ext_folder is not None
|
|
assert ext_folder["is_external"] is True
|
|
assert ext_folder["external_readonly"] is True
|
|
|
|
|
|
class TestExternalFolderScan:
|
|
"""Tests for POST /library/folders/{id}/scan."""
|
|
|
|
@pytest.fixture
|
|
def external_dir(self, tmp_path):
|
|
"""Create a temporary directory with test files."""
|
|
ext_dir = tmp_path / "prints"
|
|
ext_dir.mkdir()
|
|
(ext_dir / "benchy.3mf").write_bytes(b"fake3mf")
|
|
(ext_dir / "bracket.stl").write_bytes(b"fakestl")
|
|
(ext_dir / "print.gcode").write_text("G28\nG1 X10 Y10")
|
|
(ext_dir / "readme.txt").write_text("not a print file")
|
|
(ext_dir / ".hidden.3mf").write_bytes(b"hidden")
|
|
sub = ext_dir / "subfolder"
|
|
sub.mkdir()
|
|
(sub / "nested.stl").write_bytes(b"nested")
|
|
return ext_dir
|
|
|
|
@pytest.fixture
|
|
async def external_folder(self, async_client, db_session, external_dir):
|
|
"""Create an external folder via API."""
|
|
data = {
|
|
"name": "Scan Test",
|
|
"external_path": str(external_dir),
|
|
"readonly": True,
|
|
"show_hidden": False,
|
|
}
|
|
response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
return response.json()
|
|
|
|
@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."""
|
|
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 skip: readme.txt (unsupported), .hidden.3mf (hidden)
|
|
assert result["added"] == 4
|
|
assert result["removed"] == 0
|
|
|
|
@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
|
|
response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
|
|
assert response.status_code == 200
|
|
files = response.json()
|
|
filenames = [f["filename"] for f in files]
|
|
assert ".hidden.3mf" not in filenames
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_scan_shows_hidden_when_enabled(self, async_client: AsyncClient, db_session, external_dir):
|
|
"""Verify hidden files found when show_hidden=True."""
|
|
data = {
|
|
"name": "Show Hidden Test",
|
|
"external_path": str(external_dir),
|
|
"show_hidden": True,
|
|
}
|
|
response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
folder = response.json()
|
|
|
|
response = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
|
|
result = response.json()
|
|
# Now should also find .hidden.3mf → 5 total
|
|
assert result["added"] == 5
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_scan_idempotent(self, async_client: AsyncClient, db_session, external_folder):
|
|
"""Verify scanning twice doesn't duplicate files."""
|
|
response1 = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
|
|
assert response1.json()["added"] == 4
|
|
|
|
response2 = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
|
|
assert response2.json()["added"] == 0
|
|
assert response2.json()["removed"] == 0
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_scan_removes_deleted_files(
|
|
self, async_client: AsyncClient, db_session, external_folder, external_dir
|
|
):
|
|
"""Verify scan removes entries for files no longer on disk."""
|
|
await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
|
|
|
|
# Delete a file from disk
|
|
(external_dir / "bracket.stl").unlink()
|
|
|
|
response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
|
|
result = response.json()
|
|
assert result["removed"] == 1
|
|
assert result["added"] == 0
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_scan_non_external_folder_fails(self, async_client: AsyncClient, db_session):
|
|
"""Verify scan fails on regular (non-external) folder."""
|
|
# Create a regular folder
|
|
data = {"name": "Regular Folder"}
|
|
response = await async_client.post("/api/v1/library/folders", json=data)
|
|
folder = response.json()
|
|
|
|
response = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
|
|
assert response.status_code == 400
|
|
assert "not an external" in response.json()["detail"].lower()
|
|
|
|
@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."""
|
|
await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
|
|
|
|
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
|
|
|
|
|
|
class TestExternalFolderProtections:
|
|
"""Tests for read-only protections on external folders."""
|
|
|
|
@pytest.fixture
|
|
def external_dir(self, tmp_path):
|
|
ext_dir = tmp_path / "readonly_share"
|
|
ext_dir.mkdir()
|
|
(ext_dir / "test.stl").write_bytes(b"fakestl")
|
|
return ext_dir
|
|
|
|
@pytest.fixture
|
|
async def readonly_folder(self, async_client, db_session, external_dir):
|
|
"""Create a read-only external folder with files scanned."""
|
|
data = {
|
|
"name": "Read Only",
|
|
"external_path": str(external_dir),
|
|
"readonly": True,
|
|
}
|
|
response = await async_client.post("/api/v1/library/folders/external", json=data)
|
|
folder = response.json()
|
|
await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
|
|
return folder
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_upload_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
|
|
"""Verify uploads to read-only external folders are blocked."""
|
|
import io
|
|
|
|
file_content = io.BytesIO(b"test content")
|
|
response = await async_client.post(
|
|
f"/api/v1/library/files?folder_id={readonly_folder['id']}",
|
|
files={"file": ("test.gcode", file_content, "application/octet-stream")},
|
|
)
|
|
assert response.status_code == 403
|
|
assert "read-only" in response.json()["detail"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_move_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
|
|
"""Verify moving files to read-only external folder is blocked."""
|
|
from backend.app.models.library import LibraryFile
|
|
|
|
# Create a regular file
|
|
lib_file = LibraryFile(
|
|
filename="regular.3mf",
|
|
file_path="/test/regular.3mf",
|
|
file_size=1024,
|
|
file_type="3mf",
|
|
)
|
|
db_session.add(lib_file)
|
|
await db_session.commit()
|
|
await db_session.refresh(lib_file)
|
|
|
|
data = {"file_ids": [lib_file.id], "folder_id": readonly_folder["id"]}
|
|
response = await async_client.post("/api/v1/library/files/move", json=data)
|
|
assert response.status_code == 403
|
|
assert "read-only" in response.json()["detail"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_external_files_cannot_be_moved_out(self, async_client: AsyncClient, db_session, readonly_folder):
|
|
"""Verify external files can't be moved to other folders."""
|
|
# Get the external file ID
|
|
response = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
|
|
files = response.json()
|
|
assert len(files) > 0
|
|
ext_file_id = files[0]["id"]
|
|
|
|
# Try to move to root
|
|
data = {"file_ids": [ext_file_id], "folder_id": None}
|
|
response = await async_client.post("/api/v1/library/files/move", json=data)
|
|
assert response.status_code == 200
|
|
# File should be skipped, not moved
|
|
result = response.json()
|
|
assert result["moved"] == 0
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_delete_external_file_removes_db_only(
|
|
self, async_client: AsyncClient, db_session, readonly_folder, external_dir
|
|
):
|
|
"""Verify deleting an external file only removes DB entry, not the file on disk."""
|
|
response = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
|
|
files = response.json()
|
|
ext_file_id = files[0]["id"]
|
|
ext_filename = files[0]["filename"]
|
|
|
|
# Delete via API
|
|
response = await async_client.delete(f"/api/v1/library/files/{ext_file_id}")
|
|
assert response.status_code == 200
|
|
|
|
# File should still exist on disk
|
|
assert (external_dir / ext_filename).exists()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_delete_external_folder_preserves_files(
|
|
self, async_client: AsyncClient, db_session, readonly_folder, external_dir
|
|
):
|
|
"""Verify deleting an external folder doesn't delete files from disk."""
|
|
response = await async_client.delete(f"/api/v1/library/folders/{readonly_folder['id']}")
|
|
assert response.status_code == 200
|
|
|
|
# Files should still exist on disk
|
|
assert (external_dir / "test.stl").exists()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_zip_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
|
|
"""Verify ZIP extraction to read-only external folder is blocked."""
|
|
import io
|
|
import zipfile
|
|
|
|
# Create a minimal zip
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w") as zf:
|
|
zf.writestr("test.stl", b"fakestl")
|
|
buf.seek(0)
|
|
|
|
response = await async_client.post(
|
|
f"/api/v1/library/files/extract-zip?folder_id={readonly_folder['id']}",
|
|
files={"file": ("test.zip", buf, "application/zip")},
|
|
)
|
|
assert response.status_code == 403
|
|
assert "read-only" in response.json()["detail"].lower()
|