| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964 |
- """Integration tests for Library API endpoints."""
- import io
- import tempfile
- import zipfile
- from pathlib import Path
- import pytest
- from httpx import AsyncClient
- from sqlalchemy import select
- from backend.app.core.config import settings as app_settings
- from backend.app.models.print_queue import PrintQueueItem
- async def _read_queue_item(db_session, item_id: int) -> PrintQueueItem:
- """Re-read a queue row the route just committed through its own session."""
- db_session.expire_all()
- return (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
- class TestLibraryFoldersAPI:
- """Integration tests for library folders endpoints."""
- @pytest.fixture
- async def folder_factory(self, db_session):
- """Factory to create test folders."""
- _counter = [0]
- async def _create_folder(**kwargs):
- from backend.app.models.library import LibraryFolder
- _counter[0] += 1
- counter = _counter[0]
- defaults = {
- "name": f"Test Folder {counter}",
- }
- defaults.update(kwargs)
- folder = LibraryFolder(**defaults)
- db_session.add(folder)
- await db_session.commit()
- await db_session.refresh(folder)
- return folder
- return _create_folder
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_list_folders_empty(self, async_client: AsyncClient, db_session):
- """Verify empty folder list returns empty array."""
- response = await async_client.get("/api/v1/library/folders")
- assert response.status_code == 200
- assert response.json() == []
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_folder_tree_exposes_latest_activity_at_from_files(
- self, async_client: AsyncClient, folder_factory, db_session
- ):
- """#1770: folder list returns latest_activity_at = MAX(folder.updated_at,
- MAX(immediate-child file.updated_at)) so the frontend can sort by
- recent activity. Adding a file with a later updated_at must bubble it.
- """
- from datetime import datetime, timedelta
- from backend.app.models.library import LibraryFile
- folder = await folder_factory(name="Active Folder")
- # File whose updated_at is well after the folder's. Activity should
- # surface this timestamp, not the folder's stale one.
- future = datetime.utcnow() + timedelta(hours=24)
- db_session.add(
- LibraryFile(
- folder_id=folder.id,
- filename="model.3mf",
- file_path="library/model.3mf",
- file_type="3mf",
- file_size=123,
- updated_at=future,
- )
- )
- await db_session.commit()
- response = await async_client.get("/api/v1/library/folders")
- assert response.status_code == 200
- items = response.json()
- assert len(items) == 1
- item = items[0]
- assert item["id"] == folder.id
- assert item["latest_activity_at"] is not None
- # latest_activity_at should be at least the future stamp we set.
- assert item["latest_activity_at"] >= future.isoformat()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_folder_tree_latest_activity_at_falls_back_to_folder_updated_at(
- self, async_client: AsyncClient, folder_factory, db_session
- ):
- """#1770: a folder with no files reports its own updated_at, not null —
- otherwise the activity sort would dump every empty folder to one end."""
- await folder_factory(name="Empty Folder")
- response = await async_client.get("/api/v1/library/folders")
- assert response.status_code == 200
- items = response.json()
- assert len(items) == 1
- item = items[0]
- # latest_activity_at == folder.updated_at when there are no files
- assert item["latest_activity_at"] is not None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_create_folder(self, async_client: AsyncClient, db_session):
- """Verify folder can be created."""
- data = {"name": "New Folder"}
- response = await async_client.post("/api/v1/library/folders", json=data)
- assert response.status_code == 200
- result = response.json()
- assert result["name"] == "New Folder"
- assert result["id"] is not None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_create_nested_folder(self, async_client: AsyncClient, folder_factory, db_session):
- """Verify nested folder can be created."""
- parent = await folder_factory(name="Parent")
- data = {"name": "Child", "parent_id": parent.id}
- response = await async_client.post("/api/v1/library/folders", json=data)
- assert response.status_code == 200
- result = response.json()
- assert result["name"] == "Child"
- assert result["parent_id"] == parent.id
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_get_folder(self, async_client: AsyncClient, folder_factory, db_session):
- """Verify single folder can be retrieved."""
- folder = await folder_factory(name="Test Folder")
- response = await async_client.get(f"/api/v1/library/folders/{folder.id}")
- assert response.status_code == 200
- result = response.json()
- assert result["id"] == folder.id
- assert result["name"] == "Test Folder"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_get_folder_not_found(self, async_client: AsyncClient, db_session):
- """Verify 404 for non-existent folder."""
- response = await async_client.get("/api/v1/library/folders/9999")
- assert response.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_folder(self, async_client: AsyncClient, folder_factory, db_session):
- """Verify folder can be updated."""
- folder = await folder_factory(name="Old Name")
- data = {"name": "New Name"}
- response = await async_client.put(f"/api/v1/library/folders/{folder.id}", json=data)
- assert response.status_code == 200
- result = response.json()
- assert result["name"] == "New Name"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_delete_folder(self, async_client: AsyncClient, folder_factory, db_session):
- """Verify folder can be deleted."""
- folder = await folder_factory()
- response = await async_client.delete(f"/api/v1/library/folders/{folder.id}")
- assert response.status_code == 200
- result = response.json()
- assert result.get("message") or result.get("success", True)
- class TestLibraryFilesAPI:
- """Integration tests for library files endpoints."""
- @pytest.fixture
- async def folder_factory(self, db_session):
- """Factory to create test folders."""
- _counter = [0]
- async def _create_folder(**kwargs):
- from backend.app.models.library import LibraryFolder
- _counter[0] += 1
- counter = _counter[0]
- defaults = {"name": f"Test Folder {counter}"}
- defaults.update(kwargs)
- folder = LibraryFolder(**defaults)
- db_session.add(folder)
- await db_session.commit()
- await db_session.refresh(folder)
- return folder
- return _create_folder
- @pytest.fixture
- async def file_factory(self, db_session):
- """Factory to create test files."""
- _counter = [0]
- async def _create_file(**kwargs):
- from backend.app.models.library import LibraryFile
- _counter[0] += 1
- counter = _counter[0]
- defaults = {
- "filename": f"test_file_{counter}.3mf",
- "file_path": f"/test/path/test_file_{counter}.3mf",
- "file_size": 1024,
- "file_type": "3mf",
- }
- defaults.update(kwargs)
- lib_file = LibraryFile(**defaults)
- db_session.add(lib_file)
- await db_session.commit()
- await db_session.refresh(lib_file)
- return lib_file
- return _create_file
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_list_files_empty(self, async_client: AsyncClient, db_session):
- """Verify empty file list returns empty array."""
- response = await async_client.get("/api/v1/library/files")
- assert response.status_code == 200
- assert response.json() == []
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_list_files_in_folder(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
- """Verify files can be filtered by folder."""
- folder = await folder_factory()
- file1 = await file_factory(folder_id=folder.id)
- await file_factory() # File in root (no folder)
- response = await async_client.get(f"/api/v1/library/files?folder_id={folder.id}")
- assert response.status_code == 200
- result = response.json()
- assert len(result) == 1
- assert result[0]["id"] == file1.id
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_list_files_by_project_id(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
- """#932: project_id filter returns files across all folders linked to the project.
- Replaces the prior N+1 pattern where the frontend fired one request per
- linked folder. A single JOIN query must return every file in folders whose
- project_id matches, while excluding files from unlinked folders.
- """
- from backend.app.models.project import Project
- project = Project(name="Test Project for Files", color="#00ff00")
- db_session.add(project)
- await db_session.commit()
- await db_session.refresh(project)
- folder_a = await folder_factory(name="Folder A", project_id=project.id)
- folder_b = await folder_factory(name="Folder B", project_id=project.id)
- other_folder = await folder_factory(name="Unlinked")
- linked_a = await file_factory(folder_id=folder_a.id, filename="a.3mf")
- linked_b = await file_factory(folder_id=folder_b.id, filename="b.3mf")
- await file_factory(folder_id=other_folder.id, filename="unlinked.3mf")
- await file_factory(filename="root.3mf") # no folder → not part of any project
- response = await async_client.get(f"/api/v1/library/files?project_id={project.id}")
- assert response.status_code == 200
- result = response.json()
- ids = {f["id"] for f in result}
- assert ids == {linked_a.id, linked_b.id}
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_list_files_folder_id_takes_precedence_over_project_id(
- self, async_client: AsyncClient, folder_factory, file_factory, db_session
- ):
- """When both folder_id and project_id are passed, folder_id wins.
- Documented precedence in list_files(): folder_id > project_id > include_root.
- This guards the behavior so a future refactor can't silently flip it.
- """
- from backend.app.models.project import Project
- project = Project(name="Precedence Project")
- db_session.add(project)
- await db_session.commit()
- await db_session.refresh(project)
- folder_linked = await folder_factory(name="Linked", project_id=project.id)
- folder_other = await folder_factory(name="Other")
- await file_factory(folder_id=folder_linked.id, filename="linked.3mf")
- other_file = await file_factory(folder_id=folder_other.id, filename="other.3mf")
- # folder_id points at a folder that is NOT in the project — must return
- # that folder's contents and ignore project_id entirely.
- response = await async_client.get(f"/api/v1/library/files?folder_id={folder_other.id}&project_id={project.id}")
- assert response.status_code == 200
- result = response.json()
- assert len(result) == 1
- assert result[0]["id"] == other_file.id
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_list_files_internal_only(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
- """#1621: `internal_only=true` restricts the listing to files in managed
- storage (`is_external=False`) so a linked NAS with hundreds of files
- doesn't drown the user's own uploads in the "All Files" sidebar view."""
- internal_folder = await folder_factory(name="My uploads")
- external_folder = await folder_factory(name="NAS", is_external=True, external_path="/mnt/nas")
- internal_file = await file_factory(folder_id=internal_folder.id, filename="mine.3mf", is_external=False)
- await file_factory(folder_id=external_folder.id, filename="nas.3mf", is_external=True)
- root_file = await file_factory(filename="root.3mf", is_external=False) # Root-uploaded is always internal.
- response = await async_client.get("/api/v1/library/files?include_root=false&internal_only=true")
- assert response.status_code == 200
- ids = {f["id"] for f in response.json()}
- assert ids == {internal_file.id, root_file.id}
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_list_files_external_only(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
- """#1621 symmetric: `external_only=true` returns the combined view
- across every linked external folder so users with several mounts can
- see all external content in one place without clicking each folder."""
- internal_folder = await folder_factory(name="My uploads")
- nas_a = await folder_factory(name="NAS A", is_external=True, external_path="/mnt/a")
- nas_b = await folder_factory(name="NAS B", is_external=True, external_path="/mnt/b")
- await file_factory(folder_id=internal_folder.id, filename="mine.3mf", is_external=False)
- ext_a = await file_factory(folder_id=nas_a.id, filename="a.3mf", is_external=True)
- ext_b = await file_factory(folder_id=nas_b.id, filename="b.3mf", is_external=True)
- response = await async_client.get("/api/v1/library/files?include_root=false&external_only=true")
- assert response.status_code == 200
- ids = {f["id"] for f in response.json()}
- assert ids == {ext_a.id, ext_b.id}
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_list_files_internal_and_external_mutually_exclusive(self, async_client: AsyncClient, db_session):
- """Both flags together is a caller bug — fail loud (400) rather than
- silently picking one, so a frontend regression is caught immediately."""
- response = await async_client.get("/api/v1/library/files?internal_only=true&external_only=true")
- assert response.status_code == 400
- assert "mutually exclusive" in response.json()["detail"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_get_file(self, async_client: AsyncClient, file_factory, db_session):
- """Verify single file can be retrieved."""
- lib_file = await file_factory(filename="test.3mf")
- response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
- assert response.status_code == 200
- result = response.json()
- assert result["id"] == lib_file.id
- assert result["filename"] == "test.3mf"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_get_file_not_found(self, async_client: AsyncClient, db_session):
- """Verify 404 for non-existent file."""
- response = await async_client.get("/api/v1/library/files/9999")
- assert response.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_delete_file(self, async_client: AsyncClient, file_factory, db_session):
- """Verify file can be deleted."""
- lib_file = await file_factory()
- response = await async_client.delete(f"/api/v1/library/files/{lib_file.id}")
- assert response.status_code == 200
- result = response.json()
- assert result.get("message") or result.get("success", True)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_rename_file(self, async_client: AsyncClient, file_factory, db_session):
- """Verify file can be renamed."""
- lib_file = await file_factory(filename="old_name.3mf")
- data = {"filename": "new_name.3mf"}
- response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
- assert response.status_code == 200
- result = response.json()
- assert result["filename"] == "new_name.3mf"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_rename_file_invalid_path_separator(self, async_client: AsyncClient, file_factory, db_session):
- """Verify file rename fails with a forward slash (FAT32-illegal, #1540)."""
- lib_file = await file_factory(filename="test.3mf")
- data = {"filename": "path/to/file.3mf"}
- response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
- assert response.status_code == 400
- assert "invalid character" in response.json()["detail"].lower()
- assert "/" in response.json()["detail"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_rename_file_invalid_backslash(self, async_client: AsyncClient, file_factory, db_session):
- """Verify file rename fails with a backslash (FAT32-illegal, #1540)."""
- lib_file = await file_factory(filename="test.3mf")
- data = {"filename": "path\\to\\file.3mf"}
- response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
- assert response.status_code == 400
- assert "invalid character" in response.json()["detail"].lower()
- assert "\\" in response.json()["detail"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_library_stats(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
- """Verify library stats endpoint returns counts."""
- await folder_factory()
- await folder_factory()
- await file_factory()
- response = await async_client.get("/api/v1/library/stats")
- assert response.status_code == 200
- result = response.json()
- assert result["total_folders"] == 2
- assert result["total_files"] == 1
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_file_list_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):
- """Verify file list response includes user tracking fields (Issue #206)."""
- lib_file = await file_factory(filename="test.3mf")
- response = await async_client.get("/api/v1/library/files?include_root=false")
- assert response.status_code == 200
- result = response.json()
- assert len(result) >= 1
- # Find our test file
- test_file = next((f for f in result if f["id"] == lib_file.id), None)
- assert test_file is not None
- # User tracking fields should be present (even if null)
- assert "created_by_id" in test_file
- assert "created_by_username" in test_file
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_file_detail_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):
- """Verify file detail response includes user tracking fields (Issue #206)."""
- lib_file = await file_factory(filename="test_detail.3mf")
- response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
- assert response.status_code == 200
- result = response.json()
- # User tracking fields should be present (even if null)
- assert "created_by_id" in result
- assert "created_by_username" in result
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_file_with_user_tracking(self, async_client: AsyncClient, db_session):
- """Verify file created with user shows username in response (Issue #206)."""
- from backend.app.models.library import LibraryFile
- from backend.app.models.user import User
- # Create a test user
- user = User(username="testuploader", password_hash="fakehash", role="user")
- db_session.add(user)
- await db_session.flush()
- # Create a file with created_by_id set
- lib_file = LibraryFile(
- filename="user_uploaded.3mf",
- file_path="/test/user_uploaded.3mf",
- file_size=2048,
- file_type="3mf",
- created_by_id=user.id,
- )
- db_session.add(lib_file)
- await db_session.commit()
- await db_session.refresh(lib_file)
- # Verify file detail shows username
- response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
- assert response.status_code == 200
- result = response.json()
- assert result["created_by_id"] == user.id
- assert result["created_by_username"] == "testuploader"
- # Verify file list also shows username
- response = await async_client.get("/api/v1/library/files?include_root=false")
- assert response.status_code == 200
- files = response.json()
- test_file = next((f for f in files if f["id"] == lib_file.id), None)
- assert test_file is not None
- assert test_file["created_by_id"] == user.id
- assert test_file["created_by_username"] == "testuploader"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_list_files_recursive_includes_subfolders(
- self, async_client: AsyncClient, folder_factory, file_factory
- ):
- """#1268: ?recursive=true with folder_id must include every descendant.
- Tree:
- toys/ ← f_toys, direct file "robot_top.3mf"
- cars/ ← child of toys, file "robot_car.3mf"
- race/ ← grandchild, file "robot_race.3mf"
- other/ ← unrelated, file "robot_other.3mf" (must NOT appear)
- """
- toys = await folder_factory(name="toys")
- cars = await folder_factory(name="cars", parent_id=toys.id)
- race = await folder_factory(name="race", parent_id=cars.id)
- other = await folder_factory(name="other")
- top = await file_factory(folder_id=toys.id, filename="robot_top.3mf")
- mid = await file_factory(folder_id=cars.id, filename="robot_car.3mf")
- deep = await file_factory(folder_id=race.id, filename="robot_race.3mf")
- await file_factory(folder_id=other.id, filename="robot_other.3mf")
- # Non-recursive: only the file directly under toys.
- r = await async_client.get(f"/api/v1/library/files?folder_id={toys.id}")
- assert r.status_code == 200
- assert {f["id"] for f in r.json()} == {top.id}
- # Recursive: toys + cars + race files, but NOT other/.
- r = await async_client.get(f"/api/v1/library/files?folder_id={toys.id}&recursive=true")
- assert r.status_code == 200
- assert {f["id"] for f in r.json()} == {top.id, mid.id, deep.id}
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_list_files_recursive_without_folder_id_is_noop(
- self, async_client: AsyncClient, folder_factory, file_factory
- ):
- """recursive=true is meaningful only with folder_id — without it the
- existing include_root branch handles scoping. Just confirming the new
- param doesn't shadow that path."""
- folder = await folder_factory()
- f_in = await file_factory(folder_id=folder.id)
- f_root = await file_factory()
- r = await async_client.get("/api/v1/library/files?include_root=false&recursive=true")
- assert r.status_code == 200
- assert {f["id"] for f in r.json()} == {f_in.id, f_root.id}
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_get_folder_readme_returns_first_markdown(
- self, async_client: AsyncClient, folder_factory, file_factory
- ):
- """#1268: /folders/{id}/readme reads on-disk content of the first .md."""
- folder = await folder_factory()
- with tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") as f:
- f.write("# Robot\n\nA cute little robot.")
- md_path = f.name
- try:
- await file_factory(
- folder_id=folder.id,
- filename="README.md",
- file_path=md_path,
- file_type="md",
- file_size=Path(md_path).stat().st_size,
- )
- r = await async_client.get(f"/api/v1/library/folders/{folder.id}/readme")
- assert r.status_code == 200
- body = r.json()
- assert body["filename"] == "README.md"
- assert body["content"] == "# Robot\n\nA cute little robot."
- assert body["truncated"] is False
- finally:
- import os
- os.unlink(md_path)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_get_folder_readme_prefers_readme_over_other_md(
- self, async_client: AsyncClient, folder_factory, file_factory
- ):
- """When the folder has multiple .md files, README.md / description.md
- wins regardless of insertion order or filename case."""
- folder = await folder_factory()
- with tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") as f:
- f.write("notes notes notes")
- notes_path = f.name
- with tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") as f:
- f.write("the real one")
- readme_path = f.name
- try:
- # notes.md inserted FIRST — naive ordering would pick this one.
- await file_factory(
- folder_id=folder.id,
- filename="notes.md",
- file_path=notes_path,
- file_type="md",
- )
- await file_factory(
- folder_id=folder.id,
- filename="readme.md", # lowercase to confirm case-insensitive match
- file_path=readme_path,
- file_type="md",
- )
- r = await async_client.get(f"/api/v1/library/folders/{folder.id}/readme")
- assert r.status_code == 200
- assert r.json()["filename"] == "readme.md"
- assert r.json()["content"] == "the real one"
- finally:
- import os
- os.unlink(notes_path)
- os.unlink(readme_path)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_get_folder_readme_404_when_no_markdown(
- self, async_client: AsyncClient, folder_factory, file_factory
- ):
- """No .md in the folder → 404 so the FE can hide the side panel."""
- folder = await folder_factory()
- await file_factory(folder_id=folder.id, filename="model.3mf", file_type="3mf")
- r = await async_client.get(f"/api/v1/library/folders/{folder.id}/readme")
- assert r.status_code == 404
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_get_folder_readme_404_when_folder_missing(self, async_client: AsyncClient):
- r = await async_client.get("/api/v1/library/folders/999999/readme")
- assert r.status_code == 404
- class TestLibraryAddToQueueAPI:
- """Integration tests for /api/v1/library/files/add-to-queue endpoint."""
- @pytest.fixture
- async def printer_factory(self, db_session):
- """Factory to create test printers."""
- _counter = [0]
- async def _create_printer(**kwargs):
- from backend.app.models.printer import Printer
- _counter[0] += 1
- counter = _counter[0]
- defaults = {
- "name": f"Test Printer {counter}",
- "ip_address": f"192.168.1.{100 + counter}",
- "serial_number": f"TESTSERIAL{counter:04d}",
- "access_code": "12345678",
- "model": "X1C",
- }
- defaults.update(kwargs)
- printer = Printer(**defaults)
- db_session.add(printer)
- await db_session.commit()
- await db_session.refresh(printer)
- return printer
- return _create_printer
- @pytest.fixture
- async def library_file_factory(self, db_session):
- """Factory to create test library files."""
- _counter = [0]
- async def _create_library_file(**kwargs):
- from backend.app.models.library import LibraryFile
- _counter[0] += 1
- counter = _counter[0]
- defaults = {
- "filename": f"test_file_{counter}.gcode.3mf",
- "file_path": f"/test/path/test_file_{counter}.gcode.3mf",
- "file_size": 1024,
- "file_type": "3mf",
- }
- defaults.update(kwargs)
- lib_file = LibraryFile(**defaults)
- db_session.add(lib_file)
- await db_session.commit()
- await db_session.refresh(lib_file)
- return lib_file
- return _create_library_file
- @pytest.fixture
- async def on_disk_file_factory(self, library_file_factory):
- """A library file whose bytes exist, so the route gets past its disk check."""
- written: list[Path] = []
- async def _create(**kwargs):
- counter = len(written) + 1
- rel_path = kwargs.pop("file_path", f"archive/library/files/queue_probe_{counter}.gcode.3mf")
- abs_path = Path(app_settings.base_dir) / rel_path
- abs_path.parent.mkdir(parents=True, exist_ok=True)
- abs_path.write_bytes(b"probe")
- written.append(abs_path)
- kwargs.setdefault("filename", f"queue_probe_{counter}.gcode.3mf")
- return await library_file_factory(file_path=rel_path, **kwargs)
- yield _create
- for path in written:
- path.unlink(missing_ok=True)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_add_to_queue_file_not_found(self, async_client: AsyncClient, printer_factory, db_session):
- """Nothing queued is not a success (#3112).
- This used to assert 200: the caller got an OK for a call that created
- nothing, with the reason in a body it had no cause to read. The reason
- is still reported, now where a failed call puts it.
- """
- await printer_factory()
- data = {"file_ids": [9999]}
- response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
- assert response.status_code == 400
- errors = response.json()["detail"]["errors"]
- assert len(errors) == 1
- assert errors[0]["file_id"] == 9999
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_add_non_sliced_file_to_queue_fails(
- self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
- ):
- """Verify non-sliced file cannot be added to queue."""
- await printer_factory()
- lib_file = await library_file_factory(
- filename="model.stl",
- file_path="/test/path/model.stl",
- file_type="stl",
- )
- data = {"file_ids": [lib_file.id]}
- response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
- assert response.status_code == 400
- errors = response.json()["detail"]["errors"]
- assert len(errors) == 1
- assert "sliced" in errors[0]["error"].lower()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_partial_success_still_returns_200(
- self, async_client: AsyncClient, printer_factory, library_file_factory, on_disk_file_factory, db_session
- ):
- """Items really were created, so the call succeeded (#3112).
- The per-file errors ride along with them, which is the whole point of a
- bulk endpoint. Only a call that produced nothing is a failed call.
- """
- await printer_factory()
- good = await on_disk_file_factory()
- bad = await library_file_factory(filename="model.stl", file_path="/test/path/model.stl", file_type="stl")
- response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [good.id, bad.id]})
- assert response.status_code == 200
- result = response.json()
- assert [a["file_id"] for a in result["added"]] == [good.id]
- assert [e["file_id"] for e in result["errors"]] == [bad.id]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_target_model_is_inferred_so_the_item_can_be_dispatched(
- self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
- ):
- """#3112: an item with no printer and no target model is inert.
- The scheduler dispatches on `item.printer_id` or on
- `item.target_model or item.variants`; a row with neither matches no
- branch and waits forever. With an active X1C present, a file that says
- it was sliced for one is aimed at it.
- """
- await printer_factory(model="X1C")
- lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "X1C"})
- response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
- assert response.status_code == 200
- item = await _read_queue_item(db_session, response.json()["added"][0]["queue_item_id"])
- assert item.printer_id is None
- assert item.target_model == "X1C"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_no_printer_of_that_model_leaves_the_item_unassigned(
- self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
- ):
- """Aiming an item at hardware nobody owns would only look like progress.
- Having no H2D is the user's situation, not their mistake, so the file
- is still queued -- as the unassigned row it has always been.
- """
- await printer_factory(model="X1C")
- lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "H2D"})
- response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
- assert response.status_code == 200
- item = await _read_queue_item(db_session, response.json()["added"][0]["queue_item_id"])
- assert item.printer_id is None
- assert item.target_model is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_explicit_printer_wins_over_the_files_own_model(
- self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
- ):
- printer = await printer_factory(model="X1C")
- # Read before the queue row is re-read: that expires the session, and
- # a lazy refresh of this row would then happen outside the greenlet.
- printer_id = printer.id
- lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "X1C"})
- response = await async_client.post(
- "/api/v1/library/files/add-to-queue",
- json={"file_ids": [lib_file.id], "printer_id": printer_id},
- )
- assert response.status_code == 200
- item = await _read_queue_item(db_session, response.json()["added"][0]["queue_item_id"])
- assert item.printer_id == printer_id
- assert item.target_model is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_incompatible_target_model_is_refused_per_file(
- self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
- ):
- """The same cross-model gate POST /queue/ applies (#2578).
- The scheduler hands model-based items to hardware with no human in the
- loop, so a file sliced for one model must not be aimed at another.
- """
- await printer_factory(model="A1")
- lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "X1C"})
- response = await async_client.post(
- "/api/v1/library/files/add-to-queue",
- json={"file_ids": [lib_file.id], "target_model": "A1"},
- )
- assert response.status_code == 400
- assert "cannot be dispatched" in response.json()["detail"]["errors"][0]["error"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_target_model_without_an_active_printer_is_refused(
- self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
- ):
- await printer_factory(model="X1C")
- lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "H2D"})
- response = await async_client.post(
- "/api/v1/library/files/add-to-queue",
- json={"file_ids": [lib_file.id], "target_model": "H2D"},
- )
- assert response.status_code == 400
- assert "No active printers" in response.json()["detail"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_printer_and_target_model_together_are_refused(
- self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
- ):
- printer = await printer_factory(model="X1C")
- lib_file = await on_disk_file_factory()
- response = await async_client.post(
- "/api/v1/library/files/add-to-queue",
- json={"file_ids": [lib_file.id], "printer_id": printer.id, "target_model": "X1C"},
- )
- assert response.status_code == 400
- assert "both" in response.json()["detail"].lower()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_unknown_printer_is_refused(
- self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
- ):
- await printer_factory(model="X1C")
- lib_file = await on_disk_file_factory()
- response = await async_client.post(
- "/api/v1/library/files/add-to-queue",
- json={"file_ids": [lib_file.id], "printer_id": 999999},
- )
- assert response.status_code == 400
- assert response.json()["detail"] == "Printer not found"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_filename_the_printer_cannot_store_is_refused(
- self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
- ):
- """The Bambu SD card is FAT32; an illegal character 553s at upload.
- POST /queue/ has rejected these at queue time since #1540. This route
- did not, so the mistake surfaced as a print that failed later.
- """
- await printer_factory(model="X1C")
- lib_file = await on_disk_file_factory(filename="bad:name?.gcode.3mf")
- response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
- assert response.status_code == 400
- assert response.json()["detail"]["errors"][0]["file_id"] == lib_file.id
- class TestLibraryZipExtractAPI:
- """Integration tests for ZIP extraction endpoint."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_extract_zip_invalid_file_type(self, async_client: AsyncClient, db_session):
- """Verify non-ZIP files are rejected."""
- # Create a fake file that's not a ZIP
- files = {"file": ("test.txt", b"This is not a zip file", "text/plain")}
- response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
- assert response.status_code == 400
- assert "ZIP" in response.json()["detail"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_extract_zip_basic(self, async_client: AsyncClient, db_session):
- """Verify basic ZIP extraction works."""
- import io
- # Create a simple ZIP file in memory
- zip_buffer = io.BytesIO()
- with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
- zf.writestr("test1.txt", "Content of file 1")
- zf.writestr("test2.txt", "Content of file 2")
- zip_buffer.seek(0)
- files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
- response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
- assert response.status_code == 200
- result = response.json()
- assert result["extracted"] == 2
- assert len(result["files"]) == 2
- assert len(result["errors"]) == 0
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_extract_zip_with_folders(self, async_client: AsyncClient, db_session):
- """Verify ZIP extraction preserves folder structure."""
- import io
- # Create a ZIP file with folder structure
- zip_buffer = io.BytesIO()
- with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
- zf.writestr("folder1/file1.txt", "Content 1")
- zf.writestr("folder1/subfolder/file2.txt", "Content 2")
- zf.writestr("folder2/file3.txt", "Content 3")
- zip_buffer.seek(0)
- files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
- params = {"preserve_structure": "true"}
- response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
- assert response.status_code == 200
- result = response.json()
- assert result["extracted"] == 3
- assert result["folders_created"] >= 3 # folder1, folder1/subfolder, folder2
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_extract_zip_flat(self, async_client: AsyncClient, db_session):
- """Verify ZIP extraction can extract flat (no folders)."""
- import io
- # Create a ZIP file with folder structure
- zip_buffer = io.BytesIO()
- with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
- zf.writestr("folder/file1.txt", "Content 1")
- zf.writestr("folder/file2.txt", "Content 2")
- zip_buffer.seek(0)
- files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
- params = {"preserve_structure": "false"}
- response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
- assert response.status_code == 200
- result = response.json()
- assert result["extracted"] == 2
- assert result["folders_created"] == 0 # No folders created when flat
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_extract_zip_skips_macos_files(self, async_client: AsyncClient, db_session):
- """Verify ZIP extraction skips __MACOSX and hidden files."""
- import io
- # Create a ZIP file with macOS junk files
- zip_buffer = io.BytesIO()
- with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
- zf.writestr("real_file.txt", "Real content")
- zf.writestr("__MACOSX/._real_file.txt", "macOS metadata")
- zf.writestr(".hidden_file", "Hidden content")
- zip_buffer.seek(0)
- files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
- response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
- assert response.status_code == 200
- result = response.json()
- assert result["extracted"] == 1 # Only real_file.txt
- assert result["files"][0]["filename"] == "real_file.txt"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_extract_zip_create_folder_from_zip(self, async_client: AsyncClient, db_session):
- """Verify ZIP extraction creates a folder from the ZIP filename."""
- import io
- # Create a ZIP file with some files
- zip_buffer = io.BytesIO()
- with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
- zf.writestr("file1.txt", "Content 1")
- zf.writestr("file2.txt", "Content 2")
- zip_buffer.seek(0)
- files = {"file": ("MyProject.zip", zip_buffer.read(), "application/zip")}
- params = {"create_folder_from_zip": "true", "preserve_structure": "false"}
- response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
- assert response.status_code == 200
- result = response.json()
- assert result["extracted"] == 2
- assert result["folders_created"] == 1 # MyProject folder created
- # Verify the files are in a folder
- assert result["files"][0]["folder_id"] is not None
- assert result["files"][1]["folder_id"] is not None
- # Both files should be in the same folder
- assert result["files"][0]["folder_id"] == result["files"][1]["folder_id"]
- # Verify the folder was created with the right name
- folder_response = await async_client.get(f"/api/v1/library/folders/{result['files'][0]['folder_id']}")
- assert folder_response.status_code == 200
- folder = folder_response.json()
- assert folder["name"] == "MyProject"
- class TestLibraryStlThumbnailAPI:
- """Integration tests for STL thumbnail generation endpoints."""
- @pytest.fixture
- async def file_factory(self, db_session):
- """Factory to create test files."""
- _counter = [0]
- async def _create_file(**kwargs):
- from backend.app.models.library import LibraryFile
- _counter[0] += 1
- counter = _counter[0]
- defaults = {
- "filename": f"test_model_{counter}.stl",
- "file_path": f"/test/path/test_model_{counter}.stl",
- "file_size": 1024,
- "file_type": "stl",
- }
- defaults.update(kwargs)
- lib_file = LibraryFile(**defaults)
- db_session.add(lib_file)
- await db_session.commit()
- await db_session.refresh(lib_file)
- return lib_file
- return _create_file
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_batch_generate_thumbnails_empty(self, async_client: AsyncClient, db_session):
- """Verify batch thumbnail generation with no files."""
- data = {"all_missing": True}
- response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
- assert response.status_code == 200
- result = response.json()
- assert result["processed"] == 0
- assert result["succeeded"] == 0
- assert result["failed"] == 0
- assert result["results"] == []
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_batch_generate_thumbnails_no_criteria(self, async_client: AsyncClient, db_session):
- """Verify batch thumbnail generation with no criteria returns empty."""
- data = {}
- response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
- assert response.status_code == 200
- result = response.json()
- assert result["processed"] == 0
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_batch_generate_thumbnails_file_not_on_disk(
- self, async_client: AsyncClient, file_factory, db_session
- ):
- """Verify batch thumbnail generation handles missing files gracefully."""
- # Create a file in DB but not on disk
- stl_file = await file_factory(
- filename="missing.stl",
- file_path="/nonexistent/path/missing.stl",
- thumbnail_path=None,
- )
- data = {"file_ids": [stl_file.id]}
- response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
- assert response.status_code == 200
- result = response.json()
- assert result["processed"] == 1
- assert result["succeeded"] == 0
- assert result["failed"] == 1
- assert result["results"][0]["success"] is False
- assert "not found" in result["results"][0]["error"].lower()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_batch_generate_thumbnails_with_real_stl(self, async_client: AsyncClient, db_session):
- """Verify batch thumbnail generation with a real STL file."""
- from backend.app.models.library import LibraryFile
- # Create a simple ASCII STL cube
- stl_content = """solid cube
- facet normal 0 0 -1
- outer loop
- vertex 0 0 0
- vertex 1 0 0
- vertex 1 1 0
- endloop
- endfacet
- facet normal 0 0 1
- outer loop
- vertex 0 0 1
- vertex 1 1 1
- vertex 1 0 1
- endloop
- endfacet
- endsolid cube"""
- with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="w") as f:
- f.write(stl_content)
- stl_path = f.name
- try:
- # Create file in DB pointing to real STL
- lib_file = LibraryFile(
- filename="test_cube.stl",
- file_path=stl_path,
- file_size=len(stl_content),
- file_type="stl",
- thumbnail_path=None,
- )
- db_session.add(lib_file)
- await db_session.commit()
- await db_session.refresh(lib_file)
- data = {"file_ids": [lib_file.id]}
- response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
- assert response.status_code == 200
- result = response.json()
- assert result["processed"] == 1
- # Result depends on whether trimesh/matplotlib are installed
- # Either succeeds or fails gracefully
- assert result["succeeded"] + result["failed"] == 1
- finally:
- import os
- if os.path.exists(stl_path):
- os.unlink(stl_path)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_upload_file_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):
- """Verify file upload accepts generate_stl_thumbnails parameter."""
- # Create a simple STL file
- stl_content = b"solid test\nendsolid test"
- files = {"file": ("test.stl", stl_content, "application/octet-stream")}
- params = {"generate_stl_thumbnails": "false"}
- response = await async_client.post("/api/v1/library/files", files=files, params=params)
- assert response.status_code == 200
- result = response.json()
- assert result["filename"] == "test.stl"
- assert result["file_type"] == "stl"
- # No thumbnail should be generated when disabled
- assert result["thumbnail_path"] is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_upload_image_stores_a_thumbnail(self, async_client: AsyncClient, db_session):
- """An uploaded image gets its own grid thumbnail, so PNGs are not blank cards (#2976)."""
- from PIL import Image
- buffer = io.BytesIO()
- Image.new("RGB", (640, 480), (0, 174, 66)).save(buffer, format="PNG")
- files = {"file": ("photo.png", buffer.getvalue(), "image/png")}
- response = await async_client.post("/api/v1/library/files", files=files)
- assert response.status_code == 200
- result = response.json()
- assert result["file_type"] == "png"
- assert result["thumbnail_path"]
- from backend.app.api.routes.library import to_absolute_path
- thumb = to_absolute_path(result["thumbnail_path"])
- assert thumb is not None and thumb.exists()
- with Image.open(thumb) as thumbnail:
- assert thumbnail.format == "PNG"
- assert max(thumbnail.size) <= 256
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_extract_zip_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):
- """Verify ZIP extraction accepts generate_stl_thumbnails parameter."""
- # Create a ZIP file containing an STL
- stl_content = b"solid test\nendsolid test"
- zip_buffer = io.BytesIO()
- with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
- zf.writestr("model.stl", stl_content)
- zip_buffer.seek(0)
- files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
- params = {"generate_stl_thumbnails": "false"}
- response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
- assert response.status_code == 200
- result = response.json()
- assert result["extracted"] == 1
- assert result["files"][0]["filename"] == "model.stl"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_batch_generate_thumbnails_by_folder(self, async_client: AsyncClient, file_factory, db_session):
- """Verify batch thumbnail generation can filter by folder."""
- from backend.app.models.library import LibraryFolder
- # Create a folder
- folder = LibraryFolder(name="STL Folder")
- db_session.add(folder)
- await db_session.commit()
- await db_session.refresh(folder)
- # Create STL file in folder (no thumbnail)
- stl_in_folder = await file_factory(
- filename="in_folder.stl",
- folder_id=folder.id,
- thumbnail_path=None,
- )
- # Create STL file at root (no thumbnail)
- _stl_at_root = await file_factory(
- filename="at_root.stl",
- folder_id=None,
- thumbnail_path=None,
- )
- # Request thumbnails only for files in folder
- data = {"folder_id": folder.id, "all_missing": True}
- response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
- assert response.status_code == 200
- result = response.json()
- # Should only process the file in the folder
- assert result["processed"] == 1
- assert result["results"][0]["file_id"] == stl_in_folder.id
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_batch_generate_thumbnails_all_missing(self, async_client: AsyncClient, file_factory, db_session):
- """Verify batch thumbnail generation finds all STL files missing thumbnails."""
- # Create files with and without thumbnails
- _stl_with_thumb = await file_factory(
- filename="with_thumb.stl",
- thumbnail_path="/some/path/thumb.png",
- )
- stl_without_thumb1 = await file_factory(
- filename="without_thumb1.stl",
- thumbnail_path=None,
- )
- stl_without_thumb2 = await file_factory(
- filename="without_thumb2.stl",
- thumbnail_path=None,
- )
- data = {"all_missing": True}
- response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
- assert response.status_code == 200
- result = response.json()
- # Should only process files without thumbnails
- assert result["processed"] == 2
- file_ids = {r["file_id"] for r in result["results"]}
- assert stl_without_thumb1.id in file_ids
- assert stl_without_thumb2.id in file_ids
- class TestLibraryPathHelpers:
- """Tests for path handling utilities used for backup portability."""
- def test_to_relative_path_converts_absolute(self):
- """Verify absolute paths are converted to relative paths."""
- from backend.app.api.routes.library import to_relative_path
- from backend.app.core.config import settings
- base_dir = str(settings.base_dir)
- abs_path = f"{base_dir}/archive/library/files/test.3mf"
- rel_path = to_relative_path(abs_path)
- assert not rel_path.startswith("/")
- assert rel_path == "archive/library/files/test.3mf"
- def test_to_relative_path_handles_path_object(self):
- """Verify Path objects are handled correctly."""
- from pathlib import Path
- from backend.app.api.routes.library import to_relative_path
- from backend.app.core.config import settings
- abs_path = Path(settings.base_dir) / "archive" / "test.3mf"
- rel_path = to_relative_path(abs_path)
- assert not rel_path.startswith("/")
- assert rel_path == "archive/test.3mf"
- def test_to_relative_path_returns_empty_for_empty_input(self):
- """Verify empty input returns empty string."""
- from backend.app.api.routes.library import to_relative_path
- assert to_relative_path("") == ""
- assert to_relative_path(None) == ""
- def test_to_absolute_path_converts_relative(self):
- """Verify relative paths are converted to absolute paths."""
- from backend.app.api.routes.library import to_absolute_path
- from backend.app.core.config import settings
- rel_path = "archive/library/files/test.3mf"
- abs_path = to_absolute_path(rel_path)
- assert abs_path is not None
- assert abs_path.is_absolute()
- assert str(abs_path) == f"{settings.base_dir}/archive/library/files/test.3mf"
- def test_to_absolute_path_handles_already_absolute(self):
- """Verify already absolute paths are returned as-is (for backwards compatibility)."""
- from backend.app.api.routes.library import to_absolute_path
- abs_path_str = "/data/archive/test.3mf"
- result = to_absolute_path(abs_path_str)
- assert result is not None
- assert str(result) == abs_path_str
- def test_to_absolute_path_returns_none_for_empty(self):
- """Verify None/empty input returns None."""
- from backend.app.api.routes.library import to_absolute_path
- assert to_absolute_path(None) is None
- assert to_absolute_path("") is None
- class TestLibraryPermissions:
- """Tests for library permission enforcement."""
- @pytest.fixture
- async def auth_setup(self, db_session):
- """Set up auth with users of different permission levels."""
- from backend.app.core.auth import create_access_token, get_password_hash
- from backend.app.models.group import Group
- from backend.app.models.settings import Settings
- from backend.app.models.user import User
- # Enable auth
- settings = Settings(key="auth_enabled", value="true")
- db_session.add(settings)
- await db_session.commit()
- # Groups are auto-seeded during db init, but we need to commit them
- await db_session.commit()
- # Get groups
- from sqlalchemy import select
- admin_group = (await db_session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
- operator_group = (await db_session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
- viewer_group = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
- password_hash = get_password_hash("password")
- # Create users
- admin_user = User(username="admin_lib", password_hash=password_hash, role="admin", is_active=True)
- admin_user.groups.append(admin_group)
- operator_user = User(username="operator_lib", password_hash=password_hash, is_active=True)
- operator_user.groups.append(operator_group)
- viewer_user = User(username="viewer_lib", password_hash=password_hash, is_active=True)
- viewer_user.groups.append(viewer_group)
- db_session.add_all([admin_user, operator_user, viewer_user])
- await db_session.commit()
- # Create tokens
- admin_token = create_access_token(data={"sub": admin_user.username})
- operator_token = create_access_token(data={"sub": operator_user.username})
- viewer_token = create_access_token(data={"sub": viewer_user.username})
- return {
- "admin_user": admin_user,
- "operator_user": operator_user,
- "viewer_user": viewer_user,
- "admin_token": admin_token,
- "operator_token": operator_token,
- "viewer_token": viewer_token,
- }
- @pytest.fixture
- async def test_file(self, db_session, auth_setup):
- """Create a test file owned by the operator user."""
- from backend.app.models.library import LibraryFile
- operator_user = auth_setup["operator_user"]
- lib_file = LibraryFile(
- filename="test.txt",
- file_path="data/archive/library/files/test.txt",
- file_type="txt",
- file_size=100,
- created_by_id=operator_user.id,
- )
- db_session.add(lib_file)
- await db_session.commit()
- await db_session.refresh(lib_file)
- return lib_file
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_list_files_requires_library_read(self, async_client: AsyncClient, db_session, auth_setup):
- """Verify list_files requires library:read permission."""
- viewer_token = auth_setup["viewer_token"]
- # Viewers have library:read, should succeed
- response = await async_client.get("/api/v1/library/files", headers={"Authorization": f"Bearer {viewer_token}"})
- assert response.status_code == 200
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_list_files_denied_without_permission(self, async_client: AsyncClient, db_session):
- """Verify list_files denied without auth when auth is enabled."""
- from backend.app.models.settings import Settings
- # Enable auth
- settings = Settings(key="auth_enabled", value="true")
- db_session.add(settings)
- await db_session.commit()
- # Request without token should fail
- response = await async_client.get("/api/v1/library/files")
- assert response.status_code == 401
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_delete_file_own_by_owner(self, async_client: AsyncClient, db_session, auth_setup, test_file):
- """Verify operator can delete their own files."""
- from pathlib import Path
- # Create actual file on disk so delete doesn't fail
- from backend.app.core.config import settings as app_settings
- file_path = Path(app_settings.base_dir) / test_file.file_path
- file_path.parent.mkdir(parents=True, exist_ok=True)
- file_path.write_text("test content")
- operator_token = auth_setup["operator_token"]
- response = await async_client.delete(
- f"/api/v1/library/files/{test_file.id}", headers={"Authorization": f"Bearer {operator_token}"}
- )
- assert response.status_code == 200
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_delete_file_own_denied_for_others_file(self, async_client: AsyncClient, db_session, auth_setup):
- """Verify operator cannot delete files owned by others."""
- # Create another operator user with a file
- from sqlalchemy import select
- from backend.app.core.auth import create_access_token
- from backend.app.models.group import Group
- from backend.app.models.library import LibraryFile
- from backend.app.models.user import User
- operator_group = (await db_session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
- from backend.app.core.auth import get_password_hash as get_pw_hash
- other_user = User(username="other_op", password_hash=get_pw_hash("password"), is_active=True)
- other_user.groups.append(operator_group)
- db_session.add(other_user)
- await db_session.commit()
- await db_session.refresh(other_user)
- # Create file owned by other user
- other_file = LibraryFile(
- filename="other.txt",
- file_path="data/archive/library/files/other.txt",
- file_type="txt",
- file_size=100,
- created_by_id=other_user.id,
- )
- db_session.add(other_file)
- await db_session.commit()
- await db_session.refresh(other_file)
- # Original operator should not be able to delete it
- operator_token = auth_setup["operator_token"]
- response = await async_client.delete(
- f"/api/v1/library/files/{other_file.id}", headers={"Authorization": f"Bearer {operator_token}"}
- )
- assert response.status_code == 403
- assert "your own files" in response.json()["detail"].lower()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_delete_file_admin_can_delete_any(self, async_client: AsyncClient, db_session, auth_setup):
- """Verify admin can delete any file."""
- from pathlib import Path
- from backend.app.core.config import settings as app_settings
- from backend.app.models.library import LibraryFile
- # Create file owned by operator
- operator_user = auth_setup["operator_user"]
- lib_file = LibraryFile(
- filename="admin_can_delete.txt",
- file_path="data/archive/library/files/admin_can_delete.txt",
- file_type="txt",
- file_size=100,
- created_by_id=operator_user.id,
- )
- db_session.add(lib_file)
- await db_session.commit()
- await db_session.refresh(lib_file)
- # Create actual file on disk
- file_path = Path(app_settings.base_dir) / lib_file.file_path
- file_path.parent.mkdir(parents=True, exist_ok=True)
- file_path.write_text("test content")
- # Admin should be able to delete it
- admin_token = auth_setup["admin_token"]
- response = await async_client.delete(
- f"/api/v1/library/files/{lib_file.id}", headers={"Authorization": f"Bearer {admin_token}"}
- )
- assert response.status_code == 200
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_viewer_cannot_delete_files(self, async_client: AsyncClient, db_session, auth_setup, test_file):
- """Verify viewer cannot delete any files."""
- viewer_token = auth_setup["viewer_token"]
- response = await async_client.delete(
- f"/api/v1/library/files/{test_file.id}", headers={"Authorization": f"Bearer {viewer_token}"}
- )
- # Viewers don't have delete_own or delete_all permissions
- assert response.status_code == 403
- # ---------- #1832: API-key curation under can_manage_library ----------
- #
- # require_ownership_permission gates API keys on `all_perm`, but the
- # library deliberately split UPDATE_OWN/DELETE_OWN (allowed under
- # can_manage_library) from UPDATE_ALL/DELETE_ALL (previously denied).
- # That made the entire curation surface (DELETE, PUT rename, POST move)
- # unreachable for API keys, including for files the key's owner uploaded.
- # The fix folds UPDATE_ALL/DELETE_ALL into can_manage_library so the
- # checker passes; LIBRARY_PURGE stays admin-only.
- @pytest.fixture
- async def manage_library_key(self, db_session, auth_setup):
- """Mint an API key owned by the admin user with can_manage_library."""
- from backend.app.core.auth import generate_api_key
- from backend.app.models.api_key import APIKey
- admin = auth_setup["admin_user"]
- full_key, key_hash, key_prefix = generate_api_key()
- row = APIKey(
- name="lib-curation",
- key_hash=key_hash,
- key_prefix=key_prefix,
- user_id=admin.id,
- can_manage_library=True,
- )
- db_session.add(row)
- await db_session.commit()
- return full_key
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_apikey_with_manage_library_can_delete_file(
- self, async_client: AsyncClient, db_session, auth_setup, test_file, manage_library_key
- ):
- """Pre-#1832 this 403'd with "administrative operations" because
- LIBRARY_DELETE_ALL wasn't in _APIKEY_SCOPE_BY_PERMISSION."""
- from pathlib import Path
- from backend.app.core.config import settings as app_settings
- # Materialise the file on disk so the delete handler doesn't 500 on
- # the path it tries to unlink.
- file_path = Path(app_settings.base_dir) / test_file.file_path
- file_path.parent.mkdir(parents=True, exist_ok=True)
- file_path.write_text("test content")
- response = await async_client.delete(
- f"/api/v1/library/files/{test_file.id}",
- headers={"X-API-Key": manage_library_key},
- )
- assert response.status_code == 200, response.text
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_apikey_with_manage_library_can_rename_file(
- self, async_client: AsyncClient, db_session, auth_setup, test_file, manage_library_key
- ):
- """PUT /library/files/{id} is gated on LIBRARY_UPDATE_ALL/OWN. Same
- #1832 path as delete."""
- response = await async_client.put(
- f"/api/v1/library/files/{test_file.id}",
- headers={"X-API-Key": manage_library_key},
- json={"filename": "renamed.txt"},
- )
- assert response.status_code == 200, response.text
- assert response.json()["filename"] == "renamed.txt"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_apikey_with_manage_library_can_move_file(
- self, async_client: AsyncClient, db_session, auth_setup, test_file, manage_library_key
- ):
- """POST /library/files/move (bulk) is gated on LIBRARY_UPDATE_ALL/OWN
- — same checker, same #1832 path."""
- # Create a target folder the move can land in.
- from backend.app.models.library import LibraryFolder
- folder = LibraryFolder(name="target")
- db_session.add(folder)
- await db_session.commit()
- await db_session.refresh(folder)
- response = await async_client.post(
- "/api/v1/library/files/move",
- headers={"X-API-Key": manage_library_key},
- json={"file_ids": [test_file.id], "folder_id": folder.id},
- )
- assert response.status_code == 200, response.text
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_apikey_without_manage_library_still_blocked(
- self, async_client: AsyncClient, db_session, auth_setup, test_file
- ):
- """Regression guard: a key WITHOUT can_manage_library must still get
- 403 — the fix widens the allowed-permission set, it doesn't bypass
- the per-key scope check."""
- from backend.app.core.auth import generate_api_key
- from backend.app.models.api_key import APIKey
- admin = auth_setup["admin_user"]
- full_key, key_hash, key_prefix = generate_api_key()
- row = APIKey(
- name="read-only",
- key_hash=key_hash,
- key_prefix=key_prefix,
- user_id=admin.id,
- can_read_status=True,
- can_manage_library=False,
- )
- db_session.add(row)
- await db_session.commit()
- response = await async_client.delete(
- f"/api/v1/library/files/{test_file.id}",
- headers={"X-API-Key": full_key},
- )
- assert response.status_code == 403
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_apikey_with_manage_library_still_cannot_purge(
- self, async_client: AsyncClient, db_session, auth_setup, manage_library_key
- ):
- """LIBRARY_PURGE deliberately stays in _APIKEY_DENIED_PERMISSIONS as
- a genuinely destructive op that bypasses the soft-delete window.
- can_manage_library does NOT grant it."""
- response = await async_client.post(
- "/api/v1/library/purge",
- headers={"X-API-Key": manage_library_key},
- json={"days_in_trash": 30},
- )
- assert response.status_code == 403
- class TestPrintFileUploadValidation:
- """#1401: pre-flight rejection of unprintable uploads at the library +
- archive routes. Smoke tests the shared ``validate_print_file_upload``
- helper through both surfaces a user can reach with a drag-drop."""
- def _valid_3mf_bytes(self, name: str = "Metadata/plate_1.gcode") -> bytes:
- """Build a minimal-but-real zip with the gcode-3mf magic in it so
- the validator's ``startswith(b"PK\\x03\\x04")`` check passes."""
- buf = io.BytesIO()
- with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
- zf.writestr(name, "; G-code\nG28\n")
- return buf.getvalue()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_library_rejects_raw_gcode_upload(self, async_client: AsyncClient, db_session):
- """``Foo.gcode`` direct uploads are blocked at the library route —
- the dispatcher would otherwise append ``.3mf`` and ship raw gcode
- to the printer as a fake 3MF."""
- files = {"file": ("plate_1.gcode", b"; raw gcode\nG28\n", "application/octet-stream")}
- response = await async_client.post("/api/v1/library/files", files=files)
- assert response.status_code == 400
- # Error message must name the actual remedy, not just say "invalid".
- assert "gcode.3mf" in response.json()["detail"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_library_rejects_non_zip_3mf_upload(self, async_client: AsyncClient, db_session):
- """A ``.3mf`` upload whose body isn't a zip is rejected — covers
- raw gcode renamed to .3mf, corrupted downloads, etc."""
- files = {"file": ("model.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
- response = await async_client.post("/api/v1/library/files", files=files)
- assert response.status_code == 400
- assert "ZIP container" in response.json()["detail"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_library_rejects_non_zip_gcode_3mf_upload(self, async_client: AsyncClient, db_session):
- """The compound-extension ``.gcode.3mf`` case is gated by the same
- zip-magic check — splitext returns just ``.3mf``, but the suffix
- match covers both."""
- files = {"file": ("plate_1.gcode.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
- response = await async_client.post("/api/v1/library/files", files=files)
- assert response.status_code == 400
- assert "ZIP container" in response.json()["detail"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_library_accepts_valid_gcode_3mf_upload(self, async_client: AsyncClient, db_session):
- """A real ``.gcode.3mf`` zip uploads successfully — the existing
- happy path is not regressed by the new validation."""
- files = {
- "file": (
- "plate_1.gcode.3mf",
- self._valid_3mf_bytes(),
- "application/zip",
- )
- }
- response = await async_client.post("/api/v1/library/files", files=files)
- assert response.status_code == 200
- result = response.json()
- assert result["filename"] == "plate_1.gcode.3mf"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_library_upload_classifies_gcode_3mf_as_compound(self, async_client: AsyncClient, db_session):
- """#1600 follow-up: upload path used to strip to the trailing
- extension and store ``file_type='3mf'`` for sliced outputs, while
- the external-folder scan stored ``file_type='gcode.3mf'``. Now
- every ingest path goes through ``classify_file_type`` and
- produces the canonical compound name."""
- files = {
- "file": (
- "sliced.gcode.3mf",
- self._valid_3mf_bytes(),
- "application/zip",
- )
- }
- response = await async_client.post("/api/v1/library/files", files=files)
- assert response.status_code == 200
- assert response.json()["file_type"] == "gcode.3mf"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_library_get_gcode_endpoint_accepts_compound_file_type(self, async_client: AsyncClient, db_session):
- """#1600 follow-up: pre-fix, ``GET /files/{id}/gcode`` only handled
- ``file_type`` of ``gcode`` or ``3mf`` and 400'd on a row whose
- ``file_type`` was ``gcode.3mf`` — exactly the rows the external-
- folder scan was creating. The gate now treats both as 3MF and
- unzips the embedded gcode the same way."""
- from backend.app.models.library import LibraryFile
- # Persist a real `.gcode.3mf` zip under file_type='gcode.3mf' so
- # the endpoint hits the new branch.
- with tempfile.NamedTemporaryFile(suffix=".gcode.3mf", delete=False) as tmp:
- tmp.write(self._valid_3mf_bytes(name="Metadata/plate_1.gcode"))
- tmp_path = tmp.name
- lib_file = LibraryFile(
- filename="sliced.gcode.3mf",
- file_path=tmp_path,
- file_type="gcode.3mf",
- file_size=Path(tmp_path).stat().st_size,
- )
- db_session.add(lib_file)
- await db_session.commit()
- await db_session.refresh(lib_file)
- response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode")
- assert response.status_code == 200
- assert b"G28" in response.content
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_library_get_gcode_recovers_legacy_gcode_type_for_3mf(self, async_client: AsyncClient, db_session):
- """#1709 regression guard. Before the fix, ``slice_and_persist``
- wrote a `.gcode.3mf` ZIP container to disk but stored the row with
- ``file_type='gcode'`` — the preview endpoint then streamed the
- ZIP body as ``text/plain`` and the embedded G-code viewer saw
- ``PK\\x03\\x04...`` instead of the toolpath. New sliced rows now
- store ``file_type='gcode.3mf'``; rows already written under the
- bug self-heal because the endpoint also detects the ZIP via the
- ``.gcode.3mf`` filename suffix when the column is still legacy."""
- from backend.app.models.library import LibraryFile
- with tempfile.NamedTemporaryFile(suffix=".gcode.3mf", delete=False) as tmp:
- tmp.write(self._valid_3mf_bytes(name="Metadata/plate_1.gcode"))
- tmp_path = tmp.name
- lib_file = LibraryFile(
- filename="legacy-sliced.gcode.3mf",
- file_path=tmp_path,
- file_type="gcode",
- file_size=Path(tmp_path).stat().st_size,
- )
- db_session.add(lib_file)
- await db_session.commit()
- await db_session.refresh(lib_file)
- response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode")
- assert response.status_code == 200
- assert response.headers["content-type"].startswith("text/plain")
- assert b"G28" in response.content
- # The whole point of #1709: must NOT be ZIP bytes shoved at the viewer.
- assert not response.content.startswith(b"PK")
- async def _multi_plate_file(self, db_session):
- """A two-plate `.gcode.3mf` written plate 2 first, as Bambu Studio does.
- The member order is copied from the file this was reported on — taking
- the first `.gcode` in the zip opened plate 2.
- """
- from backend.app.models.library import LibraryFile
- buf = io.BytesIO()
- with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
- zf.writestr("Metadata/plate_2.gcode", "; plate two\nG28\n")
- zf.writestr("Metadata/plate_1.gcode", "; plate one\nG28\n")
- with tempfile.NamedTemporaryFile(suffix=".gcode.3mf", delete=False) as tmp:
- tmp.write(buf.getvalue())
- tmp_path = tmp.name
- lib_file = LibraryFile(
- filename="two-plates.gcode.3mf",
- file_path=tmp_path,
- file_type="gcode.3mf",
- file_size=Path(tmp_path).stat().st_size,
- )
- db_session.add(lib_file)
- await db_session.commit()
- await db_session.refresh(lib_file)
- return lib_file
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_library_gcode_serves_the_requested_plate(self, async_client: AsyncClient, db_session):
- """The viewer has always sent ``?plate=``; this route took no such
- parameter, and FastAPI drops unknown query parameters without a word —
- so picking a plate did nothing at all."""
- lib_file = await self._multi_plate_file(db_session)
- first = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode?plate=1")
- second = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode?plate=2")
- assert first.status_code == 200
- assert b"plate one" in first.content
- assert second.status_code == 200
- assert b"plate two" in second.content
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_library_gcode_without_a_plate_serves_the_first_plate(self, async_client: AsyncClient, db_session):
- """Not the first member in the zip — that is plate 2 in this file, and
- opening a multi-plate file from the File Manager passes no plate."""
- lib_file = await self._multi_plate_file(db_session)
- response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode")
- assert response.status_code == 200
- assert b"plate one" in response.content
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_library_gcode_rejects_a_plate_the_file_does_not_hold(self, async_client: AsyncClient, db_session):
- """404 rather than quietly rendering some other plate."""
- lib_file = await self._multi_plate_file(db_session)
- missing = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode?plate=3")
- zeroth = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode?plate=0")
- assert missing.status_code == 404
- assert zeroth.status_code == 400
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_library_still_accepts_non_print_extensions(self, async_client: AsyncClient, db_session):
- """STL / image / other non-print uploads bypass the validator
- entirely — Bambuddy is also a library, not just a print dispatcher."""
- files = {"file": ("model.stl", b"solid test\nendsolid test", "application/octet-stream")}
- response = await async_client.post(
- "/api/v1/library/files", files=files, params={"generate_stl_thumbnails": "false"}
- )
- assert response.status_code == 200
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_archive_upload_rejects_non_zip(self, async_client: AsyncClient, db_session):
- """``POST /archives/upload`` shares the same validator — covers the
- manual archive-upload entry point too."""
- files = {"file": ("model.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
- response = await async_client.post("/api/v1/archives/upload", files=files)
- assert response.status_code == 400
- assert "ZIP container" in response.json()["detail"]
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_archive_bulk_upload_collects_per_file_errors(self, async_client: AsyncClient, db_session):
- """The bulk-archive route reports validation failures per file and
- continues processing the remaining items — one bad upload in a
- 10-file drag-drop must not abort the whole batch."""
- good = self._valid_3mf_bytes()
- bad = b"; raw gcode\nG28\n"
- # httpx multipart with a list-of-tuples preserves order + same field name.
- files = [
- ("files", ("good.3mf", good, "application/zip")),
- ("files", ("bad.3mf", bad, "application/octet-stream")),
- ]
- response = await async_client.post("/api/v1/archives/upload-bulk", files=files)
- assert response.status_code == 200
- body = response.json()
- # The bulk route's archive_print may still reject the "good" file
- # downstream (no printer match, etc.) — we don't care about that
- # here; what matters is the bad file lands in `errors` with the
- # validator's message and the route didn't 500.
- assert body["failed"] >= 1
- bad_errors = [e for e in body["errors"] if e["filename"] == "bad.3mf"]
- assert bad_errors, body
- assert "ZIP container" in bad_errors[0]["error"]
|