| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946 |
- """Tests for the /makerworld/* route handlers.
- Mocks ``MakerWorldService`` so tests don't hit the real MakerWorld API. We
- still cover: URL validation, metadata passthrough, already-imported detection,
- source-URL-based dedupe on import, auto-creation of the MakerWorld default
- folder, canonical URL shape, filename basenaming, and the ``/recent-imports``
- listing endpoint.
- """
- from __future__ import annotations
- from datetime import datetime, timedelta
- from unittest.mock import AsyncMock, patch
- import pytest
- from backend.app.api.routes import makerworld as makerworld_routes
- from backend.app.core.permissions import Permission
- from backend.app.models.library import LibraryFile, LibraryFolder
- from backend.app.services.model_providers.base import (
- ModelProvider,
- ProviderDownload,
- ProviderDownloadInfo,
- ProviderResolvedModel,
- ProviderResourceRef,
- ProviderStatus,
- )
- from backend.app.services.model_providers.makerworld import makerworld_provider
- def _download_info(
- model_id: int = 1400373,
- profile_id: int = 298919107,
- name: str = "benchy.3mf",
- url: str = "https://makerworld.bblmw.com/makerworld/model/X/Y/f.3mf?exp=1&key=k",
- ) -> ProviderDownloadInfo:
- """What ``service.get_download`` hands the route: signed URL + raw upstream
- name + the enriched resource ref (``sub_id`` carries the resolved profile)."""
- return ProviderDownloadInfo(
- ref=ProviderResourceRef(source_type="makerworld", external_id=str(model_id), sub_id=str(profile_id)),
- url=url,
- suggested_filename=name,
- )
- def _fake_service(**stubs):
- """Build an AsyncMock MakerWorldService with the given async method stubs."""
- svc = AsyncMock()
- svc.close = AsyncMock()
- for name, value in stubs.items():
- if callable(value) and not isinstance(value, AsyncMock):
- setattr(svc, name, AsyncMock(side_effect=value))
- else:
- setattr(svc, name, AsyncMock(return_value=value))
- return svc
- class _DummyProvider(ModelProvider):
- """Stand-in for a second registered model provider.
- Lets the route tests exercise behaviour that differs from the MakerWorld
- singleton — a provider-specific default folder name (or none at all), and
- its own permissions — without registering anything in the app-wide
- registry. The permissions deliberately are *not* the MakerWorld ones: the
- routes must gate on whichever provider the request resolved to.
- """
- source_type = "dummy"
- display_name = "Dummy"
- def __init__(
- self,
- default_folder_name: str | None = "Dummy Imports",
- view_permission: Permission | None = Permission.LIBRARY_READ,
- import_permission: Permission | None = Permission.LIBRARY_UPLOAD,
- ):
- self.default_folder_name = default_folder_name
- self.view_permission = view_permission
- self.import_permission = import_permission
- async def build_service(self, *, db, user, api_key_owner=None, client=None):
- raise NotImplementedError
- def parse_url(self, url):
- return ProviderResourceRef(source_type=self.source_type, external_id="1400373", original_url=url)
- def canonical_url(self, ref):
- return f"https://dummy.example.com/models/{ref.external_id}"
- def _permission_spy():
- """Record which permission the route hands the shared gate.
- The gate itself still runs — the spy delegates to the real factory — so a
- test using it proves the wiring without loosening the check.
- """
- seen: list = []
- real = makerworld_routes.require_permission_if_auth_enabled
- def factory(*permissions):
- seen.extend(permissions)
- return real(*permissions)
- return seen, factory
- class TestThumbnail:
- """GET /makerworld/thumbnail — the anonymous CDN image proxy."""
- def _patch_service(self, svc):
- return patch("backend.app.api.routes.makerworld.MakerWorldService", return_value=svc)
- @pytest.mark.asyncio
- async def test_proxies_image_with_immutable_cache(self, async_client):
- from unittest.mock import MagicMock
- svc = MagicMock()
- svc.fetch_thumbnail = AsyncMock(return_value=(b"png-bytes", "image/png"))
- svc.close = AsyncMock()
- with self._patch_service(svc):
- resp = await async_client.get(
- "/api/v1/makerworld/thumbnail",
- params={"url": "https://makerworld.bblmw.com/img/x.png"},
- )
- assert resp.status_code == 200, resp.text
- assert resp.content == b"png-bytes"
- assert resp.headers["content-type"] == "image/png"
- assert "immutable" in resp.headers["cache-control"]
- # The SSRF allowlist is the provider's declared seam, not a local
- # copy inside the route (review round 2).
- assert svc.fetch_thumbnail.await_args.args[0] == "https://makerworld.bblmw.com/img/x.png"
- svc.close.assert_awaited_once()
- @pytest.mark.asyncio
- async def test_allowlist_comes_from_the_provider_descriptor(self, async_client):
- from unittest.mock import MagicMock
- svc = MagicMock()
- svc.fetch_thumbnail = AsyncMock(return_value=(b"x", "image/png"))
- svc.close = AsyncMock()
- with self._patch_service(svc) as cls:
- await async_client.get(
- "/api/v1/makerworld/thumbnail",
- params={"url": "https://makerworld.bblmw.com/img/x.png"},
- )
- assert cls.call_args.kwargs["thumbnail_hosts"] == makerworld_provider.thumbnail_hosts()
- @pytest.mark.asyncio
- async def test_non_cdn_host_is_a_clean_400(self, async_client):
- from unittest.mock import MagicMock
- from backend.app.services.model_providers.makerworld.errors import MakerWorldUrlError
- svc = MagicMock()
- svc.fetch_thumbnail = AsyncMock(
- side_effect=MakerWorldUrlError("Refusing to fetch thumbnail from non-MakerWorld host: 'evil.example'")
- )
- svc.close = AsyncMock()
- with self._patch_service(svc):
- resp = await async_client.get(
- "/api/v1/makerworld/thumbnail",
- params={"url": "https://evil.example/x.png"},
- )
- assert resp.status_code == 400
- svc.close.assert_awaited_once()
- class TestStatus:
- @pytest.mark.asyncio
- async def test_status_reports_no_token_by_default(self, async_client, db_session):
- resp = await async_client.get("/api/v1/makerworld/status")
- assert resp.status_code == 200
- body = resp.json()
- # Fresh in-memory DB has no stored token, so can_download must be false.
- # sign_in_expired is False, not True: there is no sign-in to have expired.
- assert body == {"has_cloud_token": False, "can_download": False, "sign_in_expired": False}
- @pytest.mark.asyncio
- async def test_rejected_token_blocks_download_and_reports_expired(self, async_client, db_session):
- """A token Bambu has already rejected downloads nothing. ``can_download``
- used to be a bare alias for ``has_cloud_token``, so the import button
- stayed live against a dead credential and the user only found out via a
- 401 toast."""
- from backend.app.models.settings import Settings
- from backend.app.services.bambu_cloud_credentials import CLOUD_TOKEN_INVALID_KEY, CLOUD_TOKEN_KEY
- db_session.add(Settings(key=CLOUD_TOKEN_KEY, value="dead-token"))
- db_session.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value="2026-07-14T07:00:00+00:00"))
- await db_session.commit()
- resp = await async_client.get("/api/v1/makerworld/status")
- assert resp.status_code == 200
- assert resp.json() == {
- "has_cloud_token": True,
- "can_download": False,
- "sign_in_expired": True,
- }
- @pytest.mark.asyncio
- async def test_sign_in_expired_reads_credential_rejected_not_auth_error(self, async_client):
- """The route keys ``sign_in_expired`` off the machine-readable
- ``credential_rejected`` flag, not ``auth_error`` (review round 3 note 1):
- ``auth_error`` is the human-readable reason and may be set for non-
- credential failures too. A service reporting an expired credential
- *without* a message must still surface ``sign_in_expired=True``."""
- svc = AsyncMock()
- svc.close = AsyncMock()
- svc.get_status = AsyncMock(
- return_value=ProviderStatus(
- authenticated=True,
- can_download=False,
- auth_error=None,
- credential_rejected=True,
- )
- )
- with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
- resp = await async_client.get("/api/v1/makerworld/status")
- assert resp.status_code == 200
- assert resp.json() == {
- "has_cloud_token": True,
- "can_download": False,
- "sign_in_expired": True,
- }
- class TestResolve:
- @pytest.mark.asyncio
- async def test_rejects_non_makerworld_url(self, async_client):
- resp = await async_client.post(
- "/api/v1/makerworld/resolve",
- json={"url": "https://thingiverse.com/thing/1"},
- )
- # A pasted link for an unsupported host is a clean client-input 400,
- # never a 500 — the registry guard runs before any provider call.
- assert resp.status_code == 400
- assert "provider" in resp.json()["detail"].lower()
- @pytest.mark.asyncio
- async def test_happy_path_returns_design_and_instances(self, async_client):
- design_payload = {"id": 1400373, "title": "Seed Starter"}
- instances_payload = [
- {"id": 1452154, "profileId": 298919107, "title": "9 cells"},
- {"id": 1452158, "profileId": 298919564, "title": "12 cells"},
- ]
- svc = _fake_service(
- resolve=ProviderResolvedModel(
- ref=ProviderResourceRef(source_type="makerworld", external_id="1400373", sub_id="1452154"),
- design=design_payload,
- instances=instances_payload,
- )
- )
- with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
- resp = await async_client.post(
- "/api/v1/makerworld/resolve",
- json={"url": "https://makerworld.com/en/models/1400373-slug#profileId-1452154"},
- )
- assert resp.status_code == 200, resp.text
- body = resp.json()
- assert body["model_id"] == 1400373
- assert body["profile_id"] == 1452154
- assert body["design"] == design_payload
- assert len(body["instances"]) == 2
- assert body["already_imported_library_ids"] == []
- @pytest.mark.asyncio
- async def test_flags_already_imported_library_ids(self, async_client, db_session):
- """Both dedupe shapes must be found through the provider's
- ``source_url_filter``: the whole-model canonical URL *and* any
- plate-level ``#profileId-`` row."""
- model_row = LibraryFile(
- filename="prev.3mf",
- file_path="library/files/prev.3mf",
- file_type="3mf",
- file_size=100,
- source_type="makerworld",
- source_url="https://makerworld.com/models/1400373",
- )
- plate_row = LibraryFile(
- filename="prev-plate.3mf",
- file_path="library/files/prev-plate.3mf",
- file_type="3mf",
- file_size=100,
- source_type="makerworld",
- source_url="https://makerworld.com/models/1400373#profileId-298919107",
- )
- db_session.add_all([model_row, plate_row])
- await db_session.commit()
- await db_session.refresh(model_row)
- await db_session.refresh(plate_row)
- svc = _fake_service(
- resolve=ProviderResolvedModel(
- ref=ProviderResourceRef(source_type="makerworld", external_id="1400373"),
- design={"id": 1400373},
- instances=[],
- )
- )
- with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
- resp = await async_client.post(
- "/api/v1/makerworld/resolve",
- json={"url": "https://makerworld.com/en/models/1400373"},
- )
- assert resp.status_code == 200, resp.text
- assert sorted(resp.json()["already_imported_library_ids"]) == sorted([model_row.id, plate_row.id])
- @pytest.mark.asyncio
- async def test_gate_uses_the_permission_of_the_provider_the_url_routes_to(self, async_client):
- """Same rule as import, keyed off the pasted URL instead of
- ``source_type``: a link that routes to another provider is gated on
- that provider's view permission, not ``makerworld:view``."""
- seen, factory = _permission_spy()
- dummy = _DummyProvider()
- svc = _fake_service(
- resolve=ProviderResolvedModel(
- ref=ProviderResourceRef(source_type="dummy", external_id="1400373"),
- design={"id": 1400373},
- instances=[],
- )
- )
- with (
- patch("backend.app.api.routes.makerworld.require_permission_if_auth_enabled", factory),
- patch("backend.app.api.routes.makerworld._provider_for_url", return_value=dummy),
- patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)),
- ):
- resp = await async_client.post(
- "/api/v1/makerworld/resolve",
- json={"url": "https://dummy.example.com/models/1400373"},
- )
- assert resp.status_code == 200, resp.text
- assert seen == [Permission.LIBRARY_READ]
- class TestImport:
- """End-to-end of POST /makerworld/import — mocks the service but exercises
- real DB writes, real ``save_3mf_bytes_to_library``, real folder auto-creation."""
- _FAKE_3MF_BYTES = b"PK\x03\x04not-a-real-3mf"
- @pytest.mark.asyncio
- async def test_returns_existing_on_source_url_match(self, async_client, db_session):
- """Re-importing a model we already have must NOT re-download.
- Dedupe key is ``{model_id}#profileId-{profile_id}`` — matches the
- canonical URL the route constructs, not the legacy model-only shape.
- """
- existing = LibraryFile(
- filename="already-here.3mf",
- file_path="library/files/already.3mf",
- file_type="3mf",
- file_size=500,
- source_type="makerworld",
- source_url="https://makerworld.com/models/1400373#profileId-298919107",
- )
- db_session.add(existing)
- await db_session.commit()
- await db_session.refresh(existing)
- svc = _fake_service(get_download=_download_info())
- svc.download = AsyncMock() # must remain uncalled
- with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
- resp = await async_client.post(
- "/api/v1/makerworld/import",
- json={"model_id": 1400373, "profile_id": 298919107},
- )
- assert resp.status_code == 200, resp.text
- body = resp.json()
- assert body["library_file_id"] == existing.id
- assert body["was_existing"] is True
- assert body["profile_id"] == 298919107
- svc.download.assert_not_called()
- @pytest.mark.asyncio
- async def test_unknown_source_type_is_a_clean_400(self, async_client, db_session):
- """``source_type`` names the provider (there is no URL to route on);
- an unregistered value is a client-input problem — 400 before any
- service is built or bytes downloaded."""
- svc = _fake_service(
- get_download=_download_info(),
- download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
- )
- svc.download = AsyncMock()
- with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
- resp = await async_client.post(
- "/api/v1/makerworld/import",
- json={"model_id": 1400373, "source_type": "thingiverse"},
- )
- assert resp.status_code == 400, resp.text
- assert "thingiverse" in resp.json()["detail"].lower()
- svc.download.assert_not_called()
- @pytest.mark.asyncio
- async def test_unknown_source_type_creates_no_folder_side_effect(self, async_client, db_session):
- """Provider resolution must precede destination handling — a rejected
- request must not leave an auto-created default folder behind."""
- from sqlalchemy import select
- svc = _fake_service(get_download=_download_info())
- with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
- await async_client.post(
- "/api/v1/makerworld/import",
- json={"model_id": 1400373, "source_type": "bogus"},
- )
- result = await db_session.execute(select(LibraryFolder))
- assert result.scalars().all() == []
- @pytest.mark.asyncio
- async def test_autocreates_makerworld_folder_when_folder_id_none(self, async_client, db_session):
- """Default destination — a top-level "MakerWorld" folder — is created
- on first import so users don't have to set it up."""
- svc = _fake_service(
- get_download=_download_info(),
- download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
- )
- with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
- resp = await async_client.post(
- "/api/v1/makerworld/import",
- json={"model_id": 1400373, "profile_id": 298919107, "folder_id": None},
- )
- assert resp.status_code == 200, resp.text
- # The new folder should exist, at the root.
- from sqlalchemy import select
- result = await db_session.execute(
- select(LibraryFolder).where(LibraryFolder.name == "MakerWorld", LibraryFolder.parent_id.is_(None))
- )
- folder = result.scalar_one()
- assert resp.json()["folder_id"] == folder.id
- @pytest.mark.asyncio
- async def test_default_folder_comes_from_resolved_provider(self, async_client, db_session):
- """``import_instance`` must read ``default_folder_name`` off the provider
- it resolved — not the MakerWorld singleton (review round 3 fix). Latent
- with one provider, but the difference is visible behind a stand-in: a
- second provider's import lands in *its* folder, not "MakerWorld"."""
- dummy = _DummyProvider(default_folder_name="Dummy Imports")
- svc = _fake_service(
- get_download=_download_info(),
- download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
- )
- with (
- patch("backend.app.api.routes.makerworld._provider_for_source", return_value=dummy),
- patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)),
- ):
- resp = await async_client.post(
- "/api/v1/makerworld/import",
- json={"model_id": 1400373, "profile_id": 298919107, "source_type": "dummy"},
- )
- assert resp.status_code == 200, resp.text
- from sqlalchemy import select
- result = await db_session.execute(
- select(LibraryFolder).where(LibraryFolder.name == "Dummy Imports", LibraryFolder.parent_id.is_(None))
- )
- assert result.scalar_one_or_none() is not None
- # The MakerWorld singleton's folder must NOT be auto-created instead.
- assert (
- await db_session.execute(
- select(LibraryFolder).where(LibraryFolder.name == "MakerWorld", LibraryFolder.parent_id.is_(None))
- )
- ).scalar_one_or_none() is None
- @pytest.mark.asyncio
- async def test_none_default_folder_name_imports_to_library_root(self, async_client, db_session):
- """A provider that leaves ``default_folder_name`` unset imports into the
- library root rather than minting a NULL-named folder (review round 3,
- note 3)."""
- dummy = _DummyProvider(default_folder_name=None)
- svc = _fake_service(
- get_download=_download_info(),
- download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
- )
- with (
- patch("backend.app.api.routes.makerworld._provider_for_source", return_value=dummy),
- patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)),
- ):
- resp = await async_client.post(
- "/api/v1/makerworld/import",
- json={"model_id": 1400373, "profile_id": 298919107, "source_type": "dummy", "folder_id": None},
- )
- assert resp.status_code == 200, resp.text
- assert resp.json()["folder_id"] is None
- from sqlalchemy import select
- result = await db_session.execute(select(LibraryFolder))
- assert result.scalars().all() == []
- @pytest.mark.asyncio
- async def test_gate_uses_the_makerworld_permission_for_makerworld(self, async_client):
- """Control for the test below: the default ``source_type`` still gates
- on ``makerworld:import``, exactly as the route decorator used to."""
- seen, factory = _permission_spy()
- svc = _fake_service(
- get_download=_download_info(),
- download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
- )
- with (
- patch("backend.app.api.routes.makerworld.require_permission_if_auth_enabled", factory),
- patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)),
- ):
- resp = await async_client.post(
- "/api/v1/makerworld/import",
- json={"model_id": 1400373, "profile_id": 298919107},
- )
- assert resp.status_code == 200, resp.text
- assert seen == [Permission.MAKERWORLD_IMPORT]
- @pytest.mark.asyncio
- async def test_gate_uses_the_resolved_providers_permission(self, async_client):
- """The permission is the resolved provider's, not the MakerWorld
- singleton's. It cannot be a route dependency — FastAPI resolves those
- before the body exists, so the decorator could only ever name one
- provider, and importing from a second one would be gated on
- ``makerworld:import``."""
- seen, factory = _permission_spy()
- dummy = _DummyProvider()
- svc = _fake_service(
- get_download=_download_info(),
- download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
- )
- with (
- patch("backend.app.api.routes.makerworld.require_permission_if_auth_enabled", factory),
- patch("backend.app.api.routes.makerworld._provider_for_source", return_value=dummy),
- patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)),
- ):
- resp = await async_client.post(
- "/api/v1/makerworld/import",
- json={"model_id": 1400373, "profile_id": 298919107, "source_type": "dummy"},
- )
- assert resp.status_code == 200, resp.text
- assert seen == [Permission.LIBRARY_UPLOAD]
- assert Permission.MAKERWORLD_IMPORT not in seen
- @pytest.mark.asyncio
- async def test_provider_without_a_permission_is_refused_not_waved_through(self, async_client, db_session):
- """``import_permission`` is optional on the descriptor, so "unset" must
- fail closed rather than read as "unrestricted"."""
- dummy = _DummyProvider(import_permission=None)
- svc = _fake_service(
- get_download=_download_info(),
- download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
- )
- with (
- patch("backend.app.api.routes.makerworld._provider_for_source", return_value=dummy),
- patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)),
- ):
- resp = await async_client.post(
- "/api/v1/makerworld/import",
- json={"model_id": 1400373, "profile_id": 298919107, "source_type": "dummy"},
- )
- assert resp.status_code == 500
- assert "declares no permission" in resp.json()["detail"]
- from sqlalchemy import select
- assert (await db_session.execute(select(LibraryFile))).scalars().all() == []
- @pytest.mark.asyncio
- async def test_uses_existing_folder_when_folder_id_provided(self, async_client, db_session):
- """Caller-supplied ``folder_id`` must be honoured even if the default
- ``MakerWorld`` folder also exists — no silent hijacking."""
- folder = LibraryFolder(name="MyCustomFolder", parent_id=None)
- db_session.add(folder)
- await db_session.commit()
- await db_session.refresh(folder)
- svc = _fake_service(
- get_download=_download_info(),
- download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
- )
- with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
- resp = await async_client.post(
- "/api/v1/makerworld/import",
- json={"model_id": 1400373, "profile_id": 298919107, "folder_id": folder.id},
- )
- assert resp.status_code == 200, resp.text
- assert resp.json()["folder_id"] == folder.id
- @pytest.mark.asyncio
- async def test_canonical_source_url_includes_profile_id(self, async_client, db_session):
- """The saved row's ``source_url`` must include ``#profileId-`` so two
- plates of the same model become two library rows (dedupe is per-plate)."""
- svc = _fake_service(
- get_download=_download_info(),
- download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
- )
- with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
- resp = await async_client.post(
- "/api/v1/makerworld/import",
- json={"model_id": 1400373, "profile_id": 298919107},
- )
- assert resp.status_code == 200, resp.text
- from sqlalchemy import select
- row = (
- await db_session.execute(select(LibraryFile).where(LibraryFile.id == resp.json()["library_file_id"]))
- ).scalar_one()
- assert row.source_url == "https://makerworld.com/models/1400373#profileId-298919107"
- @pytest.mark.asyncio
- async def test_filename_from_upstream_is_basenamed(self, async_client, db_session):
- """Defence-in-depth: a malicious ``name`` from the upstream manifest
- (e.g. ``"../../evil.3mf"``) must not persist path components into the
- library row. On-disk storage uses a UUID already, this is belt-and-
- braces protection for the human-readable field."""
- svc = _fake_service(
- get_download=_download_info(name="../../evil.3mf"),
- download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="fallback.3mf"),
- )
- with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
- resp = await async_client.post(
- "/api/v1/makerworld/import",
- json={"model_id": 1400373, "profile_id": 298919107},
- )
- assert resp.status_code == 200, resp.text
- assert resp.json()["filename"] == "evil.3mf"
- @pytest.mark.asyncio
- async def test_response_includes_profile_id(self, async_client, db_session):
- """UI matches imports back to the plate row via ``profile_id`` — the
- response field must always be populated, even when the caller provided
- it explicitly (rather than the backend falling back to design defaults)."""
- svc = _fake_service(
- get_download=_download_info(),
- download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
- )
- with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
- resp = await async_client.post(
- "/api/v1/makerworld/import",
- json={"model_id": 1400373, "profile_id": 298919107},
- )
- assert resp.status_code == 200, resp.text
- assert resp.json()["profile_id"] == 298919107
- @pytest.mark.asyncio
- async def test_import_to_writable_external_writes_bytes_to_mount(self, async_client, db_session, tmp_path):
- """#1645: importing into a writable external folder writes the bytes to
- ``<external_path>/<filename>`` and tags the row ``is_external=True`` —
- same shape as the multipart-upload path (#1112). Previously the bytes
- landed in the internal library dir under a UUID name while the row
- showed up under the external folder in the UI, leaving a NAS/SMB user
- unable to find their file on the mount."""
- ext_dir = tmp_path / "nas-makerworld"
- ext_dir.mkdir()
- folder = LibraryFolder(
- name="NAS Imports",
- parent_id=None,
- is_external=True,
- external_path=str(ext_dir),
- external_readonly=False,
- )
- db_session.add(folder)
- await db_session.commit()
- await db_session.refresh(folder)
- svc = _fake_service(
- get_download=_download_info(name="seed-starter.3mf"),
- download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="seed-starter.3mf"),
- )
- with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
- resp = await async_client.post(
- "/api/v1/makerworld/import",
- json={"model_id": 1400373, "profile_id": 298919107, "folder_id": folder.id},
- )
- assert resp.status_code == 200, resp.text
- from sqlalchemy import select
- row = (
- await db_session.execute(select(LibraryFile).where(LibraryFile.id == resp.json()["library_file_id"]))
- ).scalar_one()
- assert row.folder_id == folder.id
- assert row.is_external is True, "Row must be tagged external so re-scan can reconcile it"
- # External rows persist the absolute mount path (matches scan + upload paths).
- assert row.file_path == str(ext_dir / "seed-starter.3mf")
- on_disk = ext_dir / "seed-starter.3mf"
- assert on_disk.is_file(), "Bytes must land on the external mount, not in the internal library dir"
- assert on_disk.read_bytes() == self._FAKE_3MF_BYTES
- @pytest.mark.asyncio
- async def test_import_to_readonly_external_rejected_at_route(self, async_client, db_session, tmp_path):
- """The route-layer gate in ``import_instance`` rejects read-only
- external folders with 403 before any download happens — so MakerWorld
- credentials and the upstream download bandwidth aren't wasted."""
- ext_dir = tmp_path / "nas-readonly"
- ext_dir.mkdir()
- folder = LibraryFolder(
- name="NAS read-only",
- parent_id=None,
- is_external=True,
- external_path=str(ext_dir),
- external_readonly=True,
- )
- db_session.add(folder)
- await db_session.commit()
- await db_session.refresh(folder)
- svc = _fake_service(get_download=_download_info())
- svc.download = AsyncMock()
- with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
- resp = await async_client.post(
- "/api/v1/makerworld/import",
- json={"model_id": 1400373, "profile_id": 298919107, "folder_id": folder.id},
- )
- assert resp.status_code == 403, resp.text
- svc.download.assert_not_called()
- @pytest.mark.asyncio
- async def test_import_to_external_with_missing_path_returns_400(self, async_client, db_session, tmp_path):
- """If the external folder's mount has gone away (NAS unplugged, SMB
- share down), ``_resolve_upload_destination`` returns 400 before the
- write so we don't silently fall back to the internal library dir."""
- missing_dir = tmp_path / "vanished-mount" # NOTE: deliberately not created
- folder = LibraryFolder(
- name="NAS gone",
- parent_id=None,
- is_external=True,
- external_path=str(missing_dir),
- external_readonly=False,
- )
- db_session.add(folder)
- await db_session.commit()
- await db_session.refresh(folder)
- svc = _fake_service(
- get_download=_download_info(),
- download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
- )
- with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
- resp = await async_client.post(
- "/api/v1/makerworld/import",
- json={"model_id": 1400373, "profile_id": 298919107, "folder_id": folder.id},
- )
- assert resp.status_code == 400, resp.text
- assert "not accessible" in resp.text.lower()
- @pytest.mark.asyncio
- async def test_import_to_external_with_name_collision_returns_409(self, async_client, db_session, tmp_path):
- """A user-visible 409 fires when the filename already exists on the
- external mount, instead of silently overwriting a file the user put
- there outside Bambuddy."""
- ext_dir = tmp_path / "nas-collide"
- ext_dir.mkdir()
- (ext_dir / "benchy.3mf").write_bytes(b"pre-existing")
- folder = LibraryFolder(
- name="NAS collide",
- parent_id=None,
- is_external=True,
- external_path=str(ext_dir),
- external_readonly=False,
- )
- db_session.add(folder)
- await db_session.commit()
- await db_session.refresh(folder)
- svc = _fake_service(
- get_download=_download_info(name="benchy.3mf"),
- download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
- )
- with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
- resp = await async_client.post(
- "/api/v1/makerworld/import",
- json={"model_id": 1400373, "profile_id": 298919107, "folder_id": folder.id},
- )
- assert resp.status_code == 409, resp.text
- # Pre-existing file's contents must not be clobbered by the failed write.
- assert (ext_dir / "benchy.3mf").read_bytes() == b"pre-existing"
- class TestRecentImports:
- """GET /makerworld/recent-imports — sidebar feed on the MakerWorld page."""
- @pytest.mark.asyncio
- async def test_empty_when_no_makerworld_imports(self, async_client):
- resp = await async_client.get("/api/v1/makerworld/recent-imports")
- assert resp.status_code == 200
- assert resp.json() == []
- @pytest.mark.asyncio
- async def test_returns_items_newest_first(self, async_client, db_session):
- # Seed three rows with explicit, decreasing created_at timestamps so
- # ordering doesn't depend on auto-increment PK ordering.
- base = datetime(2025, 1, 1, 12, 0, 0)
- older = LibraryFile(
- filename="older.3mf",
- file_path="library/older.3mf",
- file_type="3mf",
- file_size=10,
- source_type="makerworld",
- source_url="https://makerworld.com/models/1",
- created_at=base,
- )
- middle = LibraryFile(
- filename="middle.3mf",
- file_path="library/middle.3mf",
- file_type="3mf",
- file_size=10,
- source_type="makerworld",
- source_url="https://makerworld.com/models/2",
- created_at=base + timedelta(hours=1),
- )
- newer = LibraryFile(
- filename="newer.3mf",
- file_path="library/newer.3mf",
- file_type="3mf",
- file_size=10,
- source_type="makerworld",
- source_url="https://makerworld.com/models/3",
- created_at=base + timedelta(hours=2),
- )
- # Unrelated non-MakerWorld file must NOT show up.
- other = LibraryFile(
- filename="manual.3mf",
- file_path="library/manual.3mf",
- file_type="3mf",
- file_size=10,
- source_type=None,
- source_url=None,
- created_at=base + timedelta(hours=3),
- )
- db_session.add_all([older, middle, newer, other])
- await db_session.commit()
- resp = await async_client.get("/api/v1/makerworld/recent-imports")
- assert resp.status_code == 200, resp.text
- body = resp.json()
- names = [row["filename"] for row in body]
- assert names == ["newer.3mf", "middle.3mf", "older.3mf"]
- @pytest.mark.asyncio
- async def test_response_matches_pydantic_shape(self, async_client, db_session):
- """Lock the exact key set so the frontend's typed ``MakerworldRecentImport``
- doesn't silently fall out of sync with the backend schema."""
- row = LibraryFile(
- filename="x.3mf",
- file_path="library/x.3mf",
- file_type="3mf",
- file_size=10,
- source_type="makerworld",
- source_url="https://makerworld.com/models/1#profileId-2",
- )
- db_session.add(row)
- await db_session.commit()
- resp = await async_client.get("/api/v1/makerworld/recent-imports")
- assert resp.status_code == 200, resp.text
- item = resp.json()[0]
- assert set(item.keys()) == {
- "library_file_id",
- "filename",
- "folder_id",
- "thumbnail_path",
- "source_url",
- "created_at",
- }
- assert item["source_url"] == "https://makerworld.com/models/1#profileId-2"
- @pytest.mark.asyncio
- async def test_limit_is_honoured(self, async_client, db_session):
- for i in range(5):
- db_session.add(
- LibraryFile(
- filename=f"f{i}.3mf",
- file_path=f"library/f{i}.3mf",
- file_type="3mf",
- file_size=10,
- source_type="makerworld",
- source_url=f"https://makerworld.com/models/{i}",
- )
- )
- await db_session.commit()
- resp = await async_client.get("/api/v1/makerworld/recent-imports?limit=2")
- assert resp.status_code == 200
- assert len(resp.json()) == 2
- @pytest.mark.asyncio
- async def test_limit_clamped_to_minimum(self, async_client, db_session):
- """``limit=0`` or negative must clamp to 1 — a zero limit would be
- silently swallowed by SQL and return nothing, which is surprising."""
- db_session.add(
- LibraryFile(
- filename="one.3mf",
- file_path="library/one.3mf",
- file_type="3mf",
- file_size=10,
- source_type="makerworld",
- source_url="https://makerworld.com/models/1",
- )
- )
- await db_session.commit()
- resp = await async_client.get("/api/v1/makerworld/recent-imports?limit=0")
- assert resp.status_code == 200
- assert len(resp.json()) == 1
- @pytest.mark.asyncio
- async def test_limit_clamped_to_maximum(self, async_client, db_session):
- """``limit`` is clamped to 50 so a pathological client can't request
- the whole table. We seed 60 rows and assert the response is capped."""
- for i in range(60):
- db_session.add(
- LibraryFile(
- filename=f"f{i}.3mf",
- file_path=f"library/f{i}.3mf",
- file_type="3mf",
- file_size=10,
- source_type="makerworld",
- source_url=f"https://makerworld.com/models/{i}",
- )
- )
- await db_session.commit()
- resp = await async_client.get("/api/v1/makerworld/recent-imports?limit=9999")
- assert resp.status_code == 200
- assert len(resp.json()) == 50
|