test_library_combine.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. """Integration tests for POST /library/files/combine (STLs -> one multi-object 3MF)."""
  2. import io
  3. import re
  4. import zipfile
  5. import pytest
  6. import trimesh
  7. from httpx import AsyncClient
  8. from sqlalchemy import select
  9. from backend.app.models.library import LibraryFile
  10. def _stl(mesh) -> bytes:
  11. return mesh.export(file_type="stl")
  12. async def _upload(client: AsyncClient, name: str, content: bytes, folder_id: int | None = None) -> int:
  13. params = {"generate_stl_thumbnails": "false"}
  14. if folder_id is not None:
  15. params["folder_id"] = str(folder_id)
  16. resp = await client.post(
  17. "/api/v1/library/files",
  18. files={"file": (name, content, "application/octet-stream")},
  19. params=params,
  20. )
  21. assert resp.status_code == 200, resp.text
  22. return resp.json()["id"]
  23. async def _download(client: AsyncClient, file_id: int) -> bytes:
  24. resp = await client.get(f"/api/v1/library/files/{file_id}/download")
  25. assert resp.status_code == 200, resp.text
  26. return resp.content
  27. class TestCombineFiles:
  28. @pytest.mark.asyncio
  29. @pytest.mark.integration
  30. async def test_combines_stls_into_new_3mf(self, async_client: AsyncClient, db_session):
  31. box_id = await _upload(async_client, "box.stl", _stl(trimesh.creation.box((30, 20, 10))))
  32. cyl_id = await _upload(async_client, "cyl.stl", _stl(trimesh.creation.cylinder(radius=12, height=25)))
  33. resp = await async_client.post(
  34. "/api/v1/library/files/combine",
  35. json={"items": [{"file_id": box_id, "copies": 2}, {"file_id": cyl_id}], "filename": "Plate"},
  36. )
  37. assert resp.status_code == 200, resp.text
  38. body = resp.json()
  39. assert body["filename"] == "Plate.3mf"
  40. assert body["file_type"] == "3mf"
  41. assert body["thumbnail_path"]
  42. row = (await db_session.execute(select(LibraryFile).where(LibraryFile.id == body["id"]))).scalar_one()
  43. assert row.source_type == "combined"
  44. content = await _download(async_client, body["id"])
  45. with zipfile.ZipFile(io.BytesIO(content)) as zf:
  46. xml = zf.read("3D/3dmodel.model").decode()
  47. assert re.findall(r'<item objectid="(\d+)"', xml) == ["1", "1", "2"]
  48. # Sources are left alone.
  49. for src in (box_id, cyl_id):
  50. assert (await async_client.get(f"/api/v1/library/files/{src}")).status_code == 200
  51. @pytest.mark.asyncio
  52. @pytest.mark.integration
  53. async def test_same_file_twice_is_one_object_with_the_copies_added(self, async_client: AsyncClient):
  54. box_id = await _upload(async_client, "box.stl", _stl(trimesh.creation.box((10, 10, 10))))
  55. cyl_id = await _upload(async_client, "cyl.stl", _stl(trimesh.creation.cylinder(radius=5, height=8)))
  56. resp = await async_client.post(
  57. "/api/v1/library/files/combine",
  58. json={
  59. "items": [
  60. {"file_id": box_id, "copies": 2},
  61. {"file_id": cyl_id},
  62. {"file_id": box_id, "copies": 3},
  63. ],
  64. "filename": "dupes",
  65. },
  66. )
  67. assert resp.status_code == 200, resp.text
  68. content = await _download(async_client, resp.json()["id"])
  69. with zipfile.ZipFile(io.BytesIO(content)) as zf:
  70. xml = zf.read("3D/3dmodel.model").decode()
  71. # The box mesh is stored once (object 1, first appearance) with 5 copies.
  72. assert re.findall(r'<object id="(\d+)" name="([^"]+)"', xml) == [("1", "box"), ("2", "cyl")]
  73. assert re.findall(r'<item objectid="(\d+)"', xml) == ["1"] * 5 + ["2"]
  74. @pytest.mark.asyncio
  75. @pytest.mark.integration
  76. async def test_lands_in_requested_folder(self, async_client: AsyncClient, db_session):
  77. folder = (await async_client.post("/api/v1/library/folders", json={"name": "Combos"})).json()
  78. box_id = await _upload(async_client, "box.stl", _stl(trimesh.creation.box((10, 10, 10))))
  79. resp = await async_client.post(
  80. "/api/v1/library/files/combine",
  81. json={"items": [{"file_id": box_id, "copies": 4}], "filename": "four.3mf", "folder_id": folder["id"]},
  82. )
  83. assert resp.status_code == 200, resp.text
  84. assert resp.json()["filename"] == "four.3mf"
  85. row = (await db_session.execute(select(LibraryFile).where(LibraryFile.id == resp.json()["id"]))).scalar_one()
  86. assert row.folder_id == folder["id"]
  87. @pytest.mark.asyncio
  88. @pytest.mark.integration
  89. async def test_rejects_non_stl_source(self, async_client: AsyncClient):
  90. buf = io.BytesIO()
  91. with zipfile.ZipFile(buf, "w") as zf:
  92. zf.writestr("3D/3dmodel.model", "<model/>")
  93. three_mf_id = await _upload(async_client, "project.3mf", buf.getvalue())
  94. resp = await async_client.post(
  95. "/api/v1/library/files/combine",
  96. json={"items": [{"file_id": three_mf_id}], "filename": "x"},
  97. )
  98. assert resp.status_code == 400
  99. assert "Only STL" in resp.json()["detail"]
  100. @pytest.mark.asyncio
  101. @pytest.mark.integration
  102. async def test_rejects_unknown_file(self, async_client: AsyncClient):
  103. resp = await async_client.post(
  104. "/api/v1/library/files/combine",
  105. json={"items": [{"file_id": 999999}], "filename": "x"},
  106. )
  107. assert resp.status_code == 404
  108. @pytest.mark.asyncio
  109. @pytest.mark.integration
  110. async def test_rejects_unknown_folder(self, async_client: AsyncClient):
  111. box_id = await _upload(async_client, "box.stl", _stl(trimesh.creation.box((10, 10, 10))))
  112. resp = await async_client.post(
  113. "/api/v1/library/files/combine",
  114. json={"items": [{"file_id": box_id}], "filename": "x", "folder_id": 999999},
  115. )
  116. assert resp.status_code == 404
  117. assert resp.json()["detail"] == "Folder not found"
  118. @pytest.mark.asyncio
  119. @pytest.mark.integration
  120. async def test_rejects_invalid_filename(self, async_client: AsyncClient):
  121. box_id = await _upload(async_client, "box.stl", _stl(trimesh.creation.box((10, 10, 10))))
  122. resp = await async_client.post(
  123. "/api/v1/library/files/combine",
  124. json={"items": [{"file_id": box_id}], "filename": "a/b"},
  125. )
  126. assert resp.status_code == 400
  127. @pytest.mark.asyncio
  128. @pytest.mark.integration
  129. async def test_rejects_too_many_objects(self, async_client: AsyncClient):
  130. box_id = await _upload(async_client, "box.stl", _stl(trimesh.creation.box((10, 10, 10))))
  131. resp = await async_client.post(
  132. "/api/v1/library/files/combine",
  133. json={"items": [{"file_id": box_id, "copies": 60}, {"file_id": box_id, "copies": 60}], "filename": "x"},
  134. )
  135. assert resp.status_code == 400
  136. assert "Too many objects" in resp.json()["detail"]
  137. @pytest.mark.asyncio
  138. @pytest.mark.integration
  139. async def test_rejects_empty_mesh(self, async_client: AsyncClient):
  140. stub_id = await _upload(async_client, "stub.stl", b"solid stub\nendsolid stub\n")
  141. resp = await async_client.post(
  142. "/api/v1/library/files/combine",
  143. json={"items": [{"file_id": stub_id}], "filename": "x"},
  144. )
  145. assert resp.status_code == 400
  146. assert "stub.stl" in resp.json()["detail"]