| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231 |
- """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()) == []
|