|
|
@@ -0,0 +1,231 @@
|
|
|
+"""Slicing a file on an external mount writes the result to that mount (#2810).
|
|
|
+
|
|
|
+Uploads learned to respect external folders in #1112 and moves in its
|
|
|
+follow-up; ``slice_and_persist`` was the last write path that still sent
|
|
|
+everything to managed storage. It kept giving the new row the external
|
|
|
+folder's ``folder_id``, so the sliced file appeared in the right folder in the
|
|
|
+File Manager while the share it was supposed to land on stayed empty -- which
|
|
|
+is why the bug could not be reproduced from the web UI at all.
|
|
|
+
|
|
|
+The fallback cases matter as much as the happy path. A slice costs minutes of
|
|
|
+CPU, so an unwritable mount must not throw the bytes away; it stores them in
|
|
|
+the managed library and *says so*, because filing the output somewhere the user
|
|
|
+is not looking with no signal is the failure this issue was made of.
|
|
|
+"""
|
|
|
+
|
|
|
+import os
|
|
|
+from pathlib import Path
|
|
|
+from unittest.mock import AsyncMock, patch
|
|
|
+
|
|
|
+import pytest
|
|
|
+
|
|
|
+from backend.app.api.routes.library import (
|
|
|
+ _resolve_slice_destination,
|
|
|
+ _unique_external_name,
|
|
|
+ slice_and_persist,
|
|
|
+)
|
|
|
+from backend.app.models.library import LibraryFile, LibraryFolder
|
|
|
+from backend.app.schemas.slicer import SliceRequest
|
|
|
+from backend.app.services.slicer_api import SliceResult
|
|
|
+
|
|
|
+
|
|
|
+def _external_folder(path: Path, *, readonly: bool = False) -> LibraryFolder:
|
|
|
+ return LibraryFolder(
|
|
|
+ name="NAS",
|
|
|
+ parent_id=None,
|
|
|
+ is_external=True,
|
|
|
+ external_path=str(path),
|
|
|
+ external_readonly=readonly,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+class TestResolveSliceDestination:
|
|
|
+ def test_managed_folder_keeps_the_uuid_name(self, tmp_path):
|
|
|
+ folder = LibraryFolder(name="Models", parent_id=None, is_external=False)
|
|
|
+
|
|
|
+ path, is_external, fallback = _resolve_slice_destination(folder, "Bidoof.gcode.3mf")
|
|
|
+
|
|
|
+ assert is_external is False
|
|
|
+ assert fallback is None
|
|
|
+ # Managed storage is content-addressed by uuid: the display name lives
|
|
|
+ # on the DB row, so two files of the same name can coexist.
|
|
|
+ assert path.name.endswith(".gcode.3mf")
|
|
|
+ assert path.name != "Bidoof.gcode.3mf"
|
|
|
+
|
|
|
+ def test_no_folder_at_all_is_managed(self):
|
|
|
+ path, is_external, fallback = _resolve_slice_destination(None, "Bidoof.gcode.3mf")
|
|
|
+
|
|
|
+ assert is_external is False
|
|
|
+ assert fallback is None
|
|
|
+ assert path.name.endswith(".gcode.3mf")
|
|
|
+
|
|
|
+ def test_writable_external_folder_gets_the_real_filename(self, tmp_path):
|
|
|
+ mount = tmp_path / "share"
|
|
|
+ mount.mkdir()
|
|
|
+
|
|
|
+ path, is_external, fallback = _resolve_slice_destination(_external_folder(mount), "Bidoof.gcode.3mf")
|
|
|
+
|
|
|
+ assert is_external is True
|
|
|
+ assert fallback is None
|
|
|
+ # The point of the whole fix: next to the source, under a name a human
|
|
|
+ # can find on the share.
|
|
|
+ assert path == mount / "Bidoof.gcode.3mf"
|
|
|
+
|
|
|
+ def test_read_only_mount_falls_back_instead_of_failing(self, tmp_path):
|
|
|
+ mount = tmp_path / "share"
|
|
|
+ mount.mkdir()
|
|
|
+
|
|
|
+ path, is_external, fallback = _resolve_slice_destination(
|
|
|
+ _external_folder(mount, readonly=True), "Bidoof.gcode.3mf"
|
|
|
+ )
|
|
|
+
|
|
|
+ assert is_external is False
|
|
|
+ assert fallback == "external_readonly"
|
|
|
+ assert path.parent != mount
|
|
|
+
|
|
|
+ def test_vanished_mount_falls_back(self, tmp_path):
|
|
|
+ missing = tmp_path / "unplugged-nas" # deliberately not created
|
|
|
+
|
|
|
+ _path, is_external, fallback = _resolve_slice_destination(_external_folder(missing), "Bidoof.gcode.3mf")
|
|
|
+
|
|
|
+ assert is_external is False
|
|
|
+ assert fallback == "external_unreachable"
|
|
|
+
|
|
|
+ def test_folder_with_no_path_configured_falls_back(self):
|
|
|
+ folder = LibraryFolder(name="NAS", parent_id=None, is_external=True, external_path=None)
|
|
|
+
|
|
|
+ _path, is_external, fallback = _resolve_slice_destination(folder, "Bidoof.gcode.3mf")
|
|
|
+
|
|
|
+ assert is_external is False
|
|
|
+ assert fallback == "external_no_path"
|
|
|
+
|
|
|
+ @pytest.mark.skipif(os.geteuid() == 0, reason="root ignores the write bit")
|
|
|
+ def test_unwritable_mount_falls_back(self, tmp_path):
|
|
|
+ mount = tmp_path / "share"
|
|
|
+ mount.mkdir()
|
|
|
+ mount.chmod(0o500)
|
|
|
+ try:
|
|
|
+ _path, is_external, fallback = _resolve_slice_destination(_external_folder(mount), "Bidoof.gcode.3mf")
|
|
|
+ finally:
|
|
|
+ mount.chmod(0o700)
|
|
|
+
|
|
|
+ assert is_external is False
|
|
|
+ assert fallback == "external_not_writable"
|
|
|
+
|
|
|
+ def test_a_name_that_escapes_the_mount_lands_in_managed_storage(self, tmp_path):
|
|
|
+ mount = tmp_path / "share"
|
|
|
+ mount.mkdir()
|
|
|
+
|
|
|
+ path, is_external, fallback = _resolve_slice_destination(_external_folder(mount), "../escaped.gcode.3mf")
|
|
|
+
|
|
|
+ # Never write outside the configured mount, whatever the name claims.
|
|
|
+ assert is_external is False
|
|
|
+ assert fallback == "external_invalid_name"
|
|
|
+ assert path.parent.resolve() != tmp_path.resolve()
|
|
|
+
|
|
|
+
|
|
|
+class TestUniqueExternalName:
|
|
|
+ def test_free_name_is_used_as_is(self, tmp_path):
|
|
|
+ assert _unique_external_name(tmp_path, "Bidoof.gcode.3mf") == "Bidoof.gcode.3mf"
|
|
|
+
|
|
|
+ def test_collision_suffixes_before_the_compound_extension(self, tmp_path):
|
|
|
+ (tmp_path / "Bidoof.gcode.3mf").write_bytes(b"first slice")
|
|
|
+
|
|
|
+ # Not "Bidoof.gcode (2).3mf" -- the whole ".gcode.3mf" is the extension
|
|
|
+ # and splitting it would produce a name the printer path won't accept.
|
|
|
+ assert _unique_external_name(tmp_path, "Bidoof.gcode.3mf") == "Bidoof (2).gcode.3mf"
|
|
|
+
|
|
|
+ def test_it_keeps_counting_past_the_first_collision(self, tmp_path):
|
|
|
+ (tmp_path / "Bidoof.gcode.3mf").write_bytes(b"first")
|
|
|
+ (tmp_path / "Bidoof (2).gcode.3mf").write_bytes(b"second")
|
|
|
+
|
|
|
+ assert _unique_external_name(tmp_path, "Bidoof.gcode.3mf") == "Bidoof (3).gcode.3mf"
|
|
|
+
|
|
|
+ def test_re_slicing_never_overwrites_what_is_already_on_the_share(self, tmp_path):
|
|
|
+ (tmp_path / "Bidoof.gcode.3mf").write_bytes(b"do not lose me")
|
|
|
+
|
|
|
+ chosen = _unique_external_name(tmp_path, "Bidoof.gcode.3mf")
|
|
|
+
|
|
|
+ assert (tmp_path / chosen).exists() is False
|
|
|
+ assert (tmp_path / "Bidoof.gcode.3mf").read_bytes() == b"do not lose me"
|
|
|
+
|
|
|
+
|
|
|
+class TestSliceAndPersistWritesToTheMount:
|
|
|
+ """End to end through ``slice_and_persist`` with the slicer stubbed out."""
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _patched_slicer(content: bytes = b"PK\x03\x04 not-a-real-3mf"):
|
|
|
+ return patch(
|
|
|
+ "backend.app.api.routes.library._run_slicer_with_fallback",
|
|
|
+ AsyncMock(return_value=(SliceResult(content, 3600, 12.5, 4200.0), False)),
|
|
|
+ )
|
|
|
+
|
|
|
+ async def _slice_into(self, db_session, folder: LibraryFolder):
|
|
|
+ db_session.add(folder)
|
|
|
+ await db_session.commit()
|
|
|
+ await db_session.refresh(folder)
|
|
|
+
|
|
|
+ with self._patched_slicer():
|
|
|
+ response = await slice_and_persist(
|
|
|
+ db_session,
|
|
|
+ model_bytes=b"source model",
|
|
|
+ model_filename="Bidoof.3mf",
|
|
|
+ folder_id=folder.id,
|
|
|
+ extra_metadata=None,
|
|
|
+ request=SliceRequest(printer_preset_id=1, process_preset_id=2, filament_preset_id=3),
|
|
|
+ current_user_id=None,
|
|
|
+ )
|
|
|
+ file_row = await db_session.get(LibraryFile, response.library_file_id)
|
|
|
+ return response, file_row
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_the_bytes_land_on_the_share(self, db_session, tmp_path):
|
|
|
+ mount = tmp_path / "share"
|
|
|
+ mount.mkdir()
|
|
|
+
|
|
|
+ response, file_row = await self._slice_into(db_session, _external_folder(mount))
|
|
|
+
|
|
|
+ assert (mount / "Bidoof.gcode.3mf").exists()
|
|
|
+ assert response.external_write_fallback is None
|
|
|
+ # The row has to agree with the disk, or the next move/scan/delete
|
|
|
+ # works on a path that isn't there.
|
|
|
+ assert file_row.is_external is True
|
|
|
+ assert file_row.file_path == str(mount / "Bidoof.gcode.3mf")
|
|
|
+ assert file_row.filename == "Bidoof.gcode.3mf"
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_the_row_records_the_suffixed_name_on_a_collision(self, db_session, tmp_path):
|
|
|
+ mount = tmp_path / "share"
|
|
|
+ mount.mkdir()
|
|
|
+ (mount / "Bidoof.gcode.3mf").write_bytes(b"an earlier slice")
|
|
|
+
|
|
|
+ _response, file_row = await self._slice_into(db_session, _external_folder(mount))
|
|
|
+
|
|
|
+ assert file_row.filename == "Bidoof (2).gcode.3mf"
|
|
|
+ assert file_row.file_path == str(mount / "Bidoof (2).gcode.3mf")
|
|
|
+ assert (mount / "Bidoof.gcode.3mf").read_bytes() == b"an earlier slice"
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_a_managed_folder_is_unaffected(self, db_session, tmp_path):
|
|
|
+ folder = LibraryFolder(name="Models", parent_id=None, is_external=False)
|
|
|
+
|
|
|
+ response, file_row = await self._slice_into(db_session, folder)
|
|
|
+
|
|
|
+ assert response.external_write_fallback is None
|
|
|
+ assert file_row.is_external is False
|
|
|
+ # Managed rows stay relative to base_dir so the install stays portable.
|
|
|
+ assert not Path(file_row.file_path).is_absolute()
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ async def test_a_read_only_mount_still_yields_a_usable_file_and_says_why(self, db_session, tmp_path):
|
|
|
+ mount = tmp_path / "share"
|
|
|
+ mount.mkdir()
|
|
|
+
|
|
|
+ response, file_row = await self._slice_into(db_session, _external_folder(mount, readonly=True))
|
|
|
+
|
|
|
+ # Minutes of slicing must not be discarded because the mount is
|
|
|
+ # read-only -- but the user has to learn where the file went.
|
|
|
+ assert response.external_write_fallback == "external_readonly"
|
|
|
+ assert file_row.is_external is False
|
|
|
+ assert (file_row.file_metadata or {}).get("external_write_fallback") == "external_readonly"
|
|
|
+ assert list(mount.iterdir()) == []
|