|
|
@@ -0,0 +1,436 @@
|
|
|
+"""Integration tests for library file notes, external link and photos (#3077).
|
|
|
+
|
|
|
+Pins the contracts of the details modal's backend: the PUT round-trip for
|
|
|
+``external_url`` (empty string clears), the list-view indicators
|
|
|
+(``has_notes`` / ``photo_count``), and the photo routes — membership check
|
|
|
+before any disk access, extension allowlist, size cap, and the photo
|
|
|
+directory going away with the file.
|
|
|
+"""
|
|
|
+
|
|
|
+import io
|
|
|
+
|
|
|
+import pytest
|
|
|
+from httpx import AsyncClient
|
|
|
+from PIL import Image
|
|
|
+
|
|
|
+from backend.app.core.config import settings as app_settings
|
|
|
+from backend.app.models.library import LibraryFile
|
|
|
+from backend.app.models.user import User
|
|
|
+from backend.app.utils.library_paths import library_photos_dir
|
|
|
+
|
|
|
+
|
|
|
+def _jpeg_bytes() -> bytes:
|
|
|
+ buf = io.BytesIO()
|
|
|
+ Image.new("RGB", (32, 32), "red").save(buf, "JPEG")
|
|
|
+ return buf.getvalue()
|
|
|
+
|
|
|
+
|
|
|
+@pytest.fixture
|
|
|
+def isolated_storage(monkeypatch, tmp_path):
|
|
|
+ """Point library storage at a throwaway directory."""
|
|
|
+ monkeypatch.setattr(app_settings, "base_dir", tmp_path)
|
|
|
+ monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
|
|
|
+ return tmp_path
|
|
|
+
|
|
|
+
|
|
|
+@pytest.fixture
|
|
|
+async def file_factory(db_session):
|
|
|
+ """Factory for LibraryFile rows with sensible defaults."""
|
|
|
+ _counter = [0]
|
|
|
+
|
|
|
+ async def _create_file(**kwargs):
|
|
|
+ _counter[0] += 1
|
|
|
+ counter = _counter[0]
|
|
|
+ defaults = {
|
|
|
+ "filename": f"part{counter}.3mf",
|
|
|
+ "file_path": f"library/files/part{counter}.3mf",
|
|
|
+ "file_type": "3mf",
|
|
|
+ "file_size": 100,
|
|
|
+ }
|
|
|
+ defaults.update(kwargs)
|
|
|
+ library_file = LibraryFile(**defaults)
|
|
|
+ db_session.add(library_file)
|
|
|
+ await db_session.commit()
|
|
|
+ await db_session.refresh(library_file)
|
|
|
+ return library_file
|
|
|
+
|
|
|
+ return _create_file
|
|
|
+
|
|
|
+
|
|
|
+async def _upload(async_client: AsyncClient, file_id: int, name: str = "result.jpg", content: bytes | None = None):
|
|
|
+ return await async_client.post(
|
|
|
+ f"/api/v1/library/files/{file_id}/photos",
|
|
|
+ files={"file": (name, content if content is not None else _jpeg_bytes(), "image/jpeg")},
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+class TestExternalUrlAndNotes:
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_external_url_round_trip(self, async_client: AsyncClient, file_factory, isolated_storage):
|
|
|
+ library_file = await file_factory()
|
|
|
+
|
|
|
+ response = await async_client.put(
|
|
|
+ f"/api/v1/library/files/{library_file.id}",
|
|
|
+ json={"external_url": "https://www.printables.com/model/1234", "notes": "Print at 0.2mm"},
|
|
|
+ )
|
|
|
+ assert response.status_code == 200
|
|
|
+ body = response.json()
|
|
|
+ assert body["external_url"] == "https://www.printables.com/model/1234"
|
|
|
+ assert body["notes"] == "Print at 0.2mm"
|
|
|
+ assert body["photos"] == []
|
|
|
+
|
|
|
+ detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
|
|
|
+ assert detail.json()["external_url"] == "https://www.printables.com/model/1234"
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_empty_string_clears_external_url(self, async_client: AsyncClient, file_factory, isolated_storage):
|
|
|
+ library_file = await file_factory(external_url="https://example.com/x")
|
|
|
+
|
|
|
+ response = await async_client.put(f"/api/v1/library/files/{library_file.id}", json={"external_url": ""})
|
|
|
+ assert response.status_code == 200
|
|
|
+ assert response.json()["external_url"] is None
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ @pytest.mark.parametrize(
|
|
|
+ "url",
|
|
|
+ ["javascript:alert(1)", "data:text/html,hi", "ftp://example.com/x", "www.printables.com/model/1"],
|
|
|
+ )
|
|
|
+ async def test_non_http_external_url_is_rejected(
|
|
|
+ self, async_client: AsyncClient, file_factory, isolated_storage, url: str
|
|
|
+ ):
|
|
|
+ library_file = await file_factory(external_url="https://example.com/x")
|
|
|
+
|
|
|
+ response = await async_client.put(f"/api/v1/library/files/{library_file.id}", json={"external_url": url})
|
|
|
+ assert response.status_code == 422
|
|
|
+
|
|
|
+ detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
|
|
|
+ assert detail.json()["external_url"] == "https://example.com/x"
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_omitted_external_url_is_left_alone(self, async_client: AsyncClient, file_factory, isolated_storage):
|
|
|
+ library_file = await file_factory(external_url="https://example.com/x")
|
|
|
+
|
|
|
+ response = await async_client.put(f"/api/v1/library/files/{library_file.id}", json={"notes": "hi"})
|
|
|
+ assert response.status_code == 200
|
|
|
+ assert response.json()["external_url"] == "https://example.com/x"
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_detail_exposes_source_url_read_only(self, async_client: AsyncClient, file_factory, isolated_storage):
|
|
|
+ library_file = await file_factory(source_type="makerworld", source_url="https://makerworld.com/models/1")
|
|
|
+
|
|
|
+ detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
|
|
|
+ assert detail.json()["source_url"] == "https://makerworld.com/models/1"
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_list_carries_indicators_but_not_notes(
|
|
|
+ self, async_client: AsyncClient, file_factory, isolated_storage
|
|
|
+ ):
|
|
|
+ with_meta = await file_factory(
|
|
|
+ notes="secret notes", external_url="https://example.com/a", photos=["a.jpg", "b.png"]
|
|
|
+ )
|
|
|
+ bare = await file_factory()
|
|
|
+
|
|
|
+ response = await async_client.get("/api/v1/library/files")
|
|
|
+ assert response.status_code == 200
|
|
|
+ by_id = {item["id"]: item for item in response.json()}
|
|
|
+
|
|
|
+ assert by_id[with_meta.id]["has_notes"] is True
|
|
|
+ assert by_id[with_meta.id]["photo_count"] == 2
|
|
|
+ assert by_id[with_meta.id]["external_url"] == "https://example.com/a"
|
|
|
+ assert "notes" not in by_id[with_meta.id]
|
|
|
+ assert "photos" not in by_id[with_meta.id]
|
|
|
+
|
|
|
+ assert by_id[bare.id]["has_notes"] is False
|
|
|
+ assert by_id[bare.id]["photo_count"] == 0
|
|
|
+ assert by_id[bare.id]["external_url"] is None
|
|
|
+
|
|
|
+
|
|
|
+class TestPhotos:
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_upload_serve_delete_round_trip(
|
|
|
+ self, async_client: AsyncClient, db_session, file_factory, isolated_storage
|
|
|
+ ):
|
|
|
+ library_file = await file_factory()
|
|
|
+
|
|
|
+ upload = await _upload(async_client, library_file.id)
|
|
|
+ assert upload.status_code == 200
|
|
|
+ body = upload.json()
|
|
|
+ filename = body["filename"]
|
|
|
+ assert body["status"] == "uploaded"
|
|
|
+ assert body["photos"] == [filename]
|
|
|
+ assert filename.endswith(".jpg")
|
|
|
+ assert (library_photos_dir(library_file.id) / filename).is_file()
|
|
|
+
|
|
|
+ await db_session.refresh(library_file)
|
|
|
+ assert library_file.photos == [filename]
|
|
|
+
|
|
|
+ served = await async_client.get(f"/api/v1/library/files/{library_file.id}/photos/{filename}")
|
|
|
+ assert served.status_code == 200
|
|
|
+ assert served.headers["content-type"] == "image/jpeg"
|
|
|
+ assert served.content == _jpeg_bytes()
|
|
|
+
|
|
|
+ detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
|
|
|
+ assert detail.json()["photos"] == [filename]
|
|
|
+
|
|
|
+ deleted = await async_client.delete(f"/api/v1/library/files/{library_file.id}/photos/{filename}")
|
|
|
+ assert deleted.status_code == 200
|
|
|
+ assert deleted.json() == {"status": "deleted", "photos": []}
|
|
|
+ assert not (library_photos_dir(library_file.id) / filename).exists()
|
|
|
+
|
|
|
+ gone = await async_client.get(f"/api/v1/library/files/{library_file.id}/photos/{filename}")
|
|
|
+ assert gone.status_code == 404
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_external_file_takes_photos_too(self, async_client: AsyncClient, file_factory, isolated_storage):
|
|
|
+ library_file = await file_factory(is_external=True, file_path="/mnt/nas/part.stl", file_type="stl")
|
|
|
+
|
|
|
+ upload = await _upload(async_client, library_file.id, name="shot.png")
|
|
|
+ assert upload.status_code == 200
|
|
|
+ assert (library_photos_dir(library_file.id) / upload.json()["filename"]).is_file()
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_unlisted_filename_is_404_even_when_on_disk(
|
|
|
+ self, async_client: AsyncClient, file_factory, isolated_storage
|
|
|
+ ):
|
|
|
+ library_file = await file_factory()
|
|
|
+ photos_dir = library_photos_dir(library_file.id)
|
|
|
+ photos_dir.mkdir(parents=True)
|
|
|
+ (photos_dir / "stray.jpg").write_bytes(_jpeg_bytes())
|
|
|
+
|
|
|
+ response = await async_client.get(f"/api/v1/library/files/{library_file.id}/photos/stray.jpg")
|
|
|
+ assert response.status_code == 404
|
|
|
+
|
|
|
+ response = await async_client.delete(f"/api/v1/library/files/{library_file.id}/photos/stray.jpg")
|
|
|
+ assert response.status_code == 404
|
|
|
+ assert (photos_dir / "stray.jpg").exists()
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_traversal_filename_is_rejected(self, async_client: AsyncClient, file_factory, isolated_storage):
|
|
|
+ # Even a traversal-looking name that IS in the stored list never leaves
|
|
|
+ # the photo directory — the membership check is not the only guard.
|
|
|
+ library_file = await file_factory(photos=["../../secret.jpg"])
|
|
|
+ (isolated_storage / "archive" / "secret.jpg").parent.mkdir(parents=True, exist_ok=True)
|
|
|
+ (isolated_storage / "archive" / "secret.jpg").write_bytes(_jpeg_bytes())
|
|
|
+
|
|
|
+ response = await async_client.get(
|
|
|
+ f"/api/v1/library/files/{library_file.id}/photos/..%2F..%2Fsecret.jpg",
|
|
|
+ )
|
|
|
+ assert response.status_code in (400, 404)
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_wrong_extension_is_rejected(self, async_client: AsyncClient, file_factory, isolated_storage):
|
|
|
+ library_file = await file_factory()
|
|
|
+
|
|
|
+ response = await _upload(async_client, library_file.id, name="notes.txt", content=b"hello")
|
|
|
+ assert response.status_code == 400
|
|
|
+ assert not library_photos_dir(library_file.id).exists()
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_oversized_upload_is_rejected(self, async_client: AsyncClient, file_factory, isolated_storage):
|
|
|
+ library_file = await file_factory()
|
|
|
+
|
|
|
+ response = await _upload(async_client, library_file.id, content=b"\xff" * (10 * 1024 * 1024 + 1))
|
|
|
+ assert response.status_code == 413
|
|
|
+ assert not library_photos_dir(library_file.id).exists()
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_upload_to_missing_file_is_404(self, async_client: AsyncClient, isolated_storage):
|
|
|
+ response = await _upload(async_client, 999999)
|
|
|
+ assert response.status_code == 404
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_trash_purge_removes_photo_dir(
|
|
|
+ self, async_client: AsyncClient, db_session, file_factory, isolated_storage
|
|
|
+ ):
|
|
|
+ library_file = await file_factory()
|
|
|
+ upload = await _upload(async_client, library_file.id)
|
|
|
+ assert upload.status_code == 200
|
|
|
+ photos_dir = library_photos_dir(library_file.id)
|
|
|
+ assert photos_dir.is_dir()
|
|
|
+
|
|
|
+ trashed = await async_client.delete(f"/api/v1/library/files/{library_file.id}")
|
|
|
+ assert trashed.status_code == 200
|
|
|
+ # Soft-delete keeps the photos, like the file bytes and thumbnail.
|
|
|
+ assert photos_dir.is_dir()
|
|
|
+
|
|
|
+ purged = await async_client.delete(f"/api/v1/library/trash/{library_file.id}")
|
|
|
+ assert purged.status_code == 200
|
|
|
+ assert not photos_dir.exists()
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_external_file_delete_removes_photo_dir(
|
|
|
+ self, async_client: AsyncClient, file_factory, isolated_storage
|
|
|
+ ):
|
|
|
+ library_file = await file_factory(is_external=True, file_path="/mnt/nas/part.stl", file_type="stl")
|
|
|
+ upload = await _upload(async_client, library_file.id)
|
|
|
+ assert upload.status_code == 200
|
|
|
+ photos_dir = library_photos_dir(library_file.id)
|
|
|
+
|
|
|
+ response = await async_client.delete(f"/api/v1/library/files/{library_file.id}")
|
|
|
+ assert response.status_code == 200
|
|
|
+ assert response.json()["trashed"] is False
|
|
|
+ assert not photos_dir.exists()
|
|
|
+
|
|
|
+
|
|
|
+class TestPhotoDirectoryCleanup:
|
|
|
+ """Every path that hard-deletes a library row takes its photos with it.
|
|
|
+
|
|
|
+ The upload/delete round-trip, the trash purge and the external single-file
|
|
|
+ delete are covered above; these are the remaining ones — folder delete,
|
|
|
+ bulk delete of files and of whole folders, the external-folder scan that
|
|
|
+ drops rows for files that vanished from the share, and the admin user
|
|
|
+ delete that takes the user's items with them.
|
|
|
+ """
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_folder_delete_removes_photo_dir(self, async_client: AsyncClient, file_factory, isolated_storage):
|
|
|
+ folder = await async_client.post("/api/v1/library/folders", json={"name": "Brackets"})
|
|
|
+ assert folder.status_code == 200
|
|
|
+ folder_id = folder.json()["id"]
|
|
|
+ library_file = await file_factory(folder_id=folder_id)
|
|
|
+ assert (await _upload(async_client, library_file.id)).status_code == 200
|
|
|
+ photos_dir = library_photos_dir(library_file.id)
|
|
|
+ assert photos_dir.is_dir()
|
|
|
+
|
|
|
+ response = await async_client.delete(f"/api/v1/library/folders/{folder_id}")
|
|
|
+ assert response.status_code == 200
|
|
|
+ assert not photos_dir.exists()
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_bulk_delete_removes_photo_dir_of_hard_deleted_file(
|
|
|
+ self, async_client: AsyncClient, file_factory, isolated_storage
|
|
|
+ ):
|
|
|
+ # External files bypass the trash, so bulk delete hard-deletes them;
|
|
|
+ # a managed file is only soft-deleted and keeps its photos until the
|
|
|
+ # sweeper runs.
|
|
|
+ external = await file_factory(is_external=True, file_path="/mnt/nas/ext.stl", file_type="stl")
|
|
|
+ managed = await file_factory()
|
|
|
+ for library_file in (external, managed):
|
|
|
+ assert (await _upload(async_client, library_file.id)).status_code == 200
|
|
|
+
|
|
|
+ response = await async_client.post(
|
|
|
+ "/api/v1/library/bulk-delete",
|
|
|
+ json={"file_ids": [external.id, managed.id], "folder_ids": []},
|
|
|
+ )
|
|
|
+ assert response.status_code == 200
|
|
|
+ assert response.json()["deleted_files"] == 2
|
|
|
+ assert not library_photos_dir(external.id).exists()
|
|
|
+ assert library_photos_dir(managed.id).is_dir()
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_bulk_delete_removes_photo_dirs_under_a_deleted_folder(
|
|
|
+ self, async_client: AsyncClient, file_factory, isolated_storage
|
|
|
+ ):
|
|
|
+ # The folder branch of bulk-delete lets the cascade hard-delete every
|
|
|
+ # row in the subtree, so it owes the same photo cleanup the file
|
|
|
+ # branch above it does — including files nested a level down.
|
|
|
+ parent = await async_client.post("/api/v1/library/folders", json={"name": "Jigs"})
|
|
|
+ assert parent.status_code == 200
|
|
|
+ parent_id = parent.json()["id"]
|
|
|
+ child = await async_client.post("/api/v1/library/folders", json={"name": "V2", "parent_id": parent_id})
|
|
|
+ assert child.status_code == 200
|
|
|
+
|
|
|
+ top_file = await file_factory(folder_id=parent_id)
|
|
|
+ nested_file = await file_factory(folder_id=child.json()["id"])
|
|
|
+ for library_file in (top_file, nested_file):
|
|
|
+ assert (await _upload(async_client, library_file.id)).status_code == 200
|
|
|
+ assert library_photos_dir(library_file.id).is_dir()
|
|
|
+
|
|
|
+ response = await async_client.post(
|
|
|
+ "/api/v1/library/bulk-delete",
|
|
|
+ json={"file_ids": [], "folder_ids": [parent_id]},
|
|
|
+ )
|
|
|
+ assert response.status_code == 200
|
|
|
+ assert response.json()["deleted_folders"] == 1
|
|
|
+ assert (await async_client.get(f"/api/v1/library/files/{top_file.id}")).status_code == 404
|
|
|
+ assert not library_photos_dir(top_file.id).exists()
|
|
|
+ assert not library_photos_dir(nested_file.id).exists()
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_deleting_a_user_with_their_items_removes_photo_dirs(
|
|
|
+ self, async_client: AsyncClient, db_session, file_factory, isolated_storage
|
|
|
+ ):
|
|
|
+ # DELETE /users/{id}?delete_items=true bulk-deletes the rows, which is
|
|
|
+ # a hard delete like any other and owes the photos with it.
|
|
|
+ owner = User(username="photo-owner", password_hash="x", role="user")
|
|
|
+ db_session.add(owner)
|
|
|
+ await db_session.commit()
|
|
|
+ await db_session.refresh(owner)
|
|
|
+
|
|
|
+ owned = await file_factory(created_by_id=owner.id)
|
|
|
+ someone_elses = await file_factory()
|
|
|
+ for library_file in (owned, someone_elses):
|
|
|
+ assert (await _upload(async_client, library_file.id)).status_code == 200
|
|
|
+ assert library_photos_dir(library_file.id).is_dir()
|
|
|
+
|
|
|
+ response = await async_client.delete(f"/api/v1/users/{owner.id}?delete_items=true")
|
|
|
+ assert response.status_code == 204
|
|
|
+ assert (await async_client.get(f"/api/v1/library/files/{owned.id}")).status_code == 404
|
|
|
+ assert not library_photos_dir(owned.id).exists()
|
|
|
+ assert library_photos_dir(someone_elses.id).is_dir()
|
|
|
+
|
|
|
+ @pytest.fixture
|
|
|
+ def external_share(self, monkeypatch, tmp_path):
|
|
|
+ """Bambuddy's data dir and an opted-in external share, as siblings.
|
|
|
+
|
|
|
+ The share cannot live under ``base_dir`` — ``_validate_external_path``
|
|
|
+ refuses to mount a Bambuddy-managed directory, and the module's
|
|
|
+ ``isolated_storage`` points ``base_dir`` at ``tmp_path`` itself.
|
|
|
+ """
|
|
|
+ data_dir = tmp_path / "data"
|
|
|
+ data_dir.mkdir()
|
|
|
+ monkeypatch.setattr(app_settings, "base_dir", data_dir)
|
|
|
+ monkeypatch.setattr(app_settings, "archive_dir", data_dir / "archive")
|
|
|
+ share = tmp_path / "share"
|
|
|
+ share.mkdir()
|
|
|
+ monkeypatch.setenv("BAMBUDDY_EXTERNAL_ROOTS", str(share))
|
|
|
+ return share
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @pytest.mark.integration
|
|
|
+ async def test_external_scan_removes_photo_dir_of_vanished_file(self, async_client: AsyncClient, external_share):
|
|
|
+ share = external_share
|
|
|
+ (share / "bracket.stl").write_bytes(b"fakestl")
|
|
|
+
|
|
|
+ folder = await async_client.post(
|
|
|
+ "/api/v1/library/folders/external",
|
|
|
+ json={"name": "Share", "external_path": str(share), "readonly": True, "show_hidden": False},
|
|
|
+ )
|
|
|
+ assert folder.status_code == 200
|
|
|
+ folder_id = folder.json()["id"]
|
|
|
+
|
|
|
+ scan = await async_client.post(f"/api/v1/library/folders/{folder_id}/scan")
|
|
|
+ assert scan.status_code == 200
|
|
|
+ assert scan.json()["added"] == 1
|
|
|
+
|
|
|
+ listing = await async_client.get(f"/api/v1/library/files?folder_id={folder_id}")
|
|
|
+ file_id = listing.json()[0]["id"]
|
|
|
+ assert (await _upload(async_client, file_id)).status_code == 200
|
|
|
+ photos_dir = library_photos_dir(file_id)
|
|
|
+ assert photos_dir.is_dir()
|
|
|
+
|
|
|
+ (share / "bracket.stl").unlink()
|
|
|
+
|
|
|
+ rescan = await async_client.post(f"/api/v1/library/folders/{folder_id}/scan")
|
|
|
+ assert rescan.status_code == 200
|
|
|
+ assert rescan.json()["removed"] == 1
|
|
|
+ assert not photos_dir.exists()
|