From 63c9f8fc1861cf68933c83f6da606969d3c5d3f0 Mon Sep 17 00:00:00 2001 From: Sebastian Keet <51016337+Person2099@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:41:33 +1000 Subject: [PATCH 1/2] Expose prefer_filename_for_name on manual archive upload endpoint POST /api/v1/archives/upload now accepts an optional prefer_filename_for_name query param, forwarded to ArchiveService.archive_print. Same flag already used by the FTP pending-uploads review flow and virtual-printer dispatch (#1152); this endpoint just didn't expose it. Default False, no behavior change for existing callers. Fixes #2609 --- backend/app/api/routes/archives.py | 11 +++- .../tests/integration/test_archives_api.py | 64 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 5342fab8c..7cc5952f1 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -3312,10 +3312,18 @@ async def get_plate_preview( async def upload_archive( file: UploadFile = File(...), printer_id: int | None = None, + prefer_filename_for_name: bool = False, db: AsyncSession = Depends(get_db), current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE), ): - """Manually upload a 3MF file to archive.""" + """Manually upload a 3MF file to archive. + + prefer_filename_for_name: When True, use the uploaded filename stem as the + archive's display name even if the 3MF embeds a `print_name` in its + metadata. Same flag already used by the FTP review flow and virtual-printer + dispatch (see ArchiveService.archive_print) — this endpoint just didn't + expose it (#1152 follow-up). + """ if not file.filename or not file.filename.endswith(".3mf"): raise HTTPException(400, "File must be a .3mf file") @@ -3341,6 +3349,7 @@ async def upload_archive( printer_id=printer_id, source_file=temp_path, created_by_id=current_user.id if current_user else None, + prefer_filename_for_name=prefer_filename_for_name, ) if not archive: diff --git a/backend/tests/integration/test_archives_api.py b/backend/tests/integration/test_archives_api.py index 0b78cf3be..450432fa3 100644 --- a/backend/tests/integration/test_archives_api.py +++ b/backend/tests/integration/test_archives_api.py @@ -4,6 +4,7 @@ Tests the full request/response cycle for /api/v1/archives/ endpoints. """ from pathlib import Path +from unittest.mock import AsyncMock, patch import pytest from httpx import AsyncClient @@ -12,6 +13,69 @@ from httpx import AsyncClient class TestArchivesAPI: """Integration tests for /api/v1/archives/ endpoints.""" + # ======================================================================== + # Upload endpoint + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + @pytest.mark.parametrize("prefer_filename_for_name", [True, False]) + async def test_upload_archive_forwards_prefer_filename_for_name( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session, prefer_filename_for_name + ): + """POST /archives/upload must forward prefer_filename_for_name to + ArchiveService.archive_print unchanged — this flag lets a caller (e.g. + the manual upload UI or an external integration) ask for the uploaded + filename to win over the 3MF's embedded print_name (#1152 follow-up: + the flag existed on the service but wasn't exposed on this route). + + archive_print is mocked (real 3MF metadata extraction isn't under test + here) but its return value is a real PrintArchive row from the + factory, so ArchiveResponse.model_validate in the route still exercises + real serialization instead of masking a broken response behind a bare + MagicMock. + """ + printer = await printer_factory() + archive = await archive_factory(printer.id, print_name="Mocked Return Archive") + archive_print_mock = AsyncMock(return_value=archive) + + files = {"file": ("My Print (final).gcode.3mf", b"PK\x03\x04fake3mf", "application/octet-stream")} + params = {"prefer_filename_for_name": prefer_filename_for_name} + + with patch( + "backend.app.api.routes.archives.ArchiveService.archive_print", + archive_print_mock, + ): + response = await async_client.post("/api/v1/archives/upload", files=files, params=params) + + assert response.status_code == 200 + assert archive_print_mock.await_count == 1 + kwargs = archive_print_mock.await_args.kwargs + assert kwargs.get("prefer_filename_for_name") is prefer_filename_for_name + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_upload_archive_defaults_prefer_filename_for_name_false( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session + ): + """Omitting the query param must not change existing behavior for + callers that predate this flag.""" + printer = await printer_factory() + archive = await archive_factory(printer.id, print_name="Mocked Return Archive") + archive_print_mock = AsyncMock(return_value=archive) + + files = {"file": ("existing-caller.gcode.3mf", b"PK\x03\x04fake3mf", "application/octet-stream")} + + with patch( + "backend.app.api.routes.archives.ArchiveService.archive_print", + archive_print_mock, + ): + response = await async_client.post("/api/v1/archives/upload", files=files) + + assert response.status_code == 200 + kwargs = archive_print_mock.await_args.kwargs + assert kwargs.get("prefer_filename_for_name") is False + # ======================================================================== # List endpoints # ======================================================================== From 93443ed7c6658ad765733d5121e041ca441f33ac Mon Sep 17 00:00:00 2001 From: Sebastian Keet <51016337+Person2099@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:06:50 +1000 Subject: [PATCH 2/2] feat: expose prefer_filename_for_name on bulk archive upload too Maintainer review on #2610 flagged that upload-bulk would diverge from upload if only the single-file route got the flag. Applies to every file in the batch, same default-off behavior. --- backend/app/api/routes/archives.py | 8 ++- .../tests/integration/test_archives_api.py | 53 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 7cc5952f1..e9c814b37 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -3365,10 +3365,15 @@ async def upload_archive( async def upload_archives_bulk( files: list[UploadFile] = File(...), printer_id: int | None = None, + prefer_filename_for_name: bool = False, db: AsyncSession = Depends(get_db), current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE), ): - """Bulk upload multiple 3MF files to archive.""" + """Bulk upload multiple 3MF files to archive. + + prefer_filename_for_name: applied to every file in the batch. See + upload_archive for details. + """ from backend.app.api.routes.library import validate_print_file_upload results = [] @@ -3403,6 +3408,7 @@ async def upload_archives_bulk( printer_id=printer_id, source_file=temp_path, created_by_id=current_user.id if current_user else None, + prefer_filename_for_name=prefer_filename_for_name, ) if archive: diff --git a/backend/tests/integration/test_archives_api.py b/backend/tests/integration/test_archives_api.py index 450432fa3..f9dbb4da1 100644 --- a/backend/tests/integration/test_archives_api.py +++ b/backend/tests/integration/test_archives_api.py @@ -76,6 +76,59 @@ class TestArchivesAPI: kwargs = archive_print_mock.await_args.kwargs assert kwargs.get("prefer_filename_for_name") is False + @pytest.mark.asyncio + @pytest.mark.integration + @pytest.mark.parametrize("prefer_filename_for_name", [True, False]) + async def test_upload_archives_bulk_forwards_prefer_filename_for_name( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session, prefer_filename_for_name + ): + """POST /archives/upload-bulk must forward prefer_filename_for_name to + ArchiveService.archive_print for every file in the batch, keeping this + route consistent with the single-file /archives/upload endpoint.""" + printer = await printer_factory() + archive = await archive_factory(printer.id, print_name="Mocked Return Archive") + archive_print_mock = AsyncMock(return_value=archive) + + files = [ + ("files", ("first.gcode.3mf", b"PK\x03\x04fake3mf", "application/octet-stream")), + ("files", ("second.gcode.3mf", b"PK\x03\x04fake3mf", "application/octet-stream")), + ] + params = {"prefer_filename_for_name": prefer_filename_for_name} + + with patch( + "backend.app.api.routes.archives.ArchiveService.archive_print", + archive_print_mock, + ): + response = await async_client.post("/api/v1/archives/upload-bulk", files=files, params=params) + + assert response.status_code == 200 + assert archive_print_mock.await_count == 2 + for call in archive_print_mock.await_args_list: + assert call.kwargs.get("prefer_filename_for_name") is prefer_filename_for_name + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_upload_archives_bulk_defaults_prefer_filename_for_name_false( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session + ): + """Omitting the query param on the bulk route must not change existing + behavior for callers that predate this flag.""" + printer = await printer_factory() + archive = await archive_factory(printer.id, print_name="Mocked Return Archive") + archive_print_mock = AsyncMock(return_value=archive) + + files = [("files", ("existing-caller.gcode.3mf", b"PK\x03\x04fake3mf", "application/octet-stream"))] + + with patch( + "backend.app.api.routes.archives.ArchiveService.archive_print", + archive_print_mock, + ): + response = await async_client.post("/api/v1/archives/upload-bulk", files=files) + + assert response.status_code == 200 + kwargs = archive_print_mock.await_args.kwargs + assert kwargs.get("prefer_filename_for_name") is False + # ======================================================================== # List endpoints # ========================================================================