mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
When reprinting a multi-plate 3MF file exported with "All sliced file",
the system now asks which plate to print and only maps filaments for
that specific plate.
Backend changes:
- Add GET /archives/{id}/plates endpoint to list available plates
with thumbnails, print times, and per-plate filament requirements
- Add GET /archives/{id}/plate-thumbnail/{index} for plate thumbnails
- Update GET /archives/{id}/filament-requirements to accept plate_id
query parameter for filtering filaments by plate
- Add plate_id field to ReprintRequest schema
- Update POST /archives/{id}/reprint to use plate_id from request body
instead of auto-detecting (maintains backward compatibility)
Frontend changes:
- Add getArchivePlates API method
- Update getArchiveFilamentRequirements to accept optional plateId
- Update reprintArchive to accept plate_id in options
- Add plate selection UI to ReprintModal when multi-plate 3MF detected
- Show plate thumbnails, names, and filament counts in selection grid
- Require plate selection before printing multi-plate files
- Filter filament requirements to show only selected plate's filaments
Tests:
- Add unit tests for multi-plate slice_info.config parsing
- Add unit tests for plate detection from gcode files
- Add integration tests for new plate endpoints
415 lines
16 KiB
Python
415 lines
16 KiB
Python
"""Integration tests for Archives API endpoints.
|
|
|
|
Tests the full request/response cycle for /api/v1/archives/ endpoints.
|
|
"""
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
|
|
class TestArchivesAPI:
|
|
"""Integration tests for /api/v1/archives/ endpoints."""
|
|
|
|
# ========================================================================
|
|
# List endpoints
|
|
# ========================================================================
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_list_archives_empty(self, async_client: AsyncClient):
|
|
"""Verify empty list is returned when no archives exist."""
|
|
response = await async_client.get("/api/v1/archives/")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert isinstance(data, list)
|
|
assert len(data) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_list_archives_with_data(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Verify list returns existing archives."""
|
|
printer = await printer_factory()
|
|
await archive_factory(printer.id, print_name="Test Archive")
|
|
|
|
response = await async_client.get("/api/v1/archives/")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert isinstance(data, list)
|
|
assert len(data) >= 1
|
|
assert any(a["print_name"] == "Test Archive" for a in data)
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_list_archives_pagination(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Verify pagination works correctly."""
|
|
printer = await printer_factory()
|
|
# Create 5 archives
|
|
for i in range(5):
|
|
await archive_factory(printer.id, print_name=f"Archive {i}")
|
|
|
|
# Get first page with limit 2
|
|
response = await async_client.get("/api/v1/archives/?limit=2&offset=0")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert isinstance(data, list)
|
|
assert len(data) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_list_archives_filter_by_printer(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Verify filtering by printer_id works."""
|
|
printer1 = await printer_factory(name="Printer 1", serial_number="00M09A000000001")
|
|
printer2 = await printer_factory(name="Printer 2", serial_number="00M09A000000002")
|
|
await archive_factory(printer1.id, print_name="Printer 1 Archive")
|
|
await archive_factory(printer2.id, print_name="Printer 2 Archive")
|
|
|
|
response = await async_client.get(f"/api/v1/archives/?printer_id={printer1.id}")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert all(a["printer_id"] == printer1.id for a in data)
|
|
|
|
# ========================================================================
|
|
# Get single endpoint
|
|
# ========================================================================
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_get_archive(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
|
|
"""Verify single archive can be retrieved."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, print_name="Get Test Archive")
|
|
|
|
response = await async_client.get(f"/api/v1/archives/{archive.id}")
|
|
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
assert result["id"] == archive.id
|
|
assert result["print_name"] == "Get Test Archive"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_get_archive_not_found(self, async_client: AsyncClient):
|
|
"""Verify 404 for non-existent archive."""
|
|
response = await async_client.get("/api/v1/archives/9999")
|
|
|
|
assert response.status_code == 404
|
|
|
|
# ========================================================================
|
|
# Update endpoints
|
|
# ========================================================================
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_update_archive_name(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
|
|
"""Verify archive name can be updated."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, print_name="Original Name")
|
|
|
|
response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"print_name": "Updated Name"})
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["print_name"] == "Updated Name"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_update_archive_notes(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
|
|
"""Verify archive notes can be updated."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id)
|
|
|
|
response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"notes": "Great print!"})
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["notes"] == "Great print!"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_update_archive_favorite(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Verify archive favorite status can be updated."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id)
|
|
|
|
response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"is_favorite": True})
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["is_favorite"] is True
|
|
|
|
# ========================================================================
|
|
# Delete endpoints
|
|
# ========================================================================
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_delete_archive(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
|
|
"""Verify archive can be deleted."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id)
|
|
archive_id = archive.id
|
|
|
|
response = await async_client.delete(f"/api/v1/archives/{archive_id}")
|
|
|
|
assert response.status_code == 200
|
|
|
|
# Verify deleted
|
|
response = await async_client.get(f"/api/v1/archives/{archive_id}")
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_delete_nonexistent_archive(self, async_client: AsyncClient):
|
|
"""Verify deleting non-existent archive returns 404."""
|
|
response = await async_client.delete("/api/v1/archives/9999")
|
|
|
|
assert response.status_code == 404
|
|
|
|
# ========================================================================
|
|
# Statistics endpoints
|
|
# ========================================================================
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_get_archive_stats(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
|
|
"""Verify archive statistics can be retrieved."""
|
|
printer = await printer_factory()
|
|
await archive_factory(
|
|
printer.id,
|
|
status="completed",
|
|
print_time_seconds=3600,
|
|
filament_used_grams=50.0,
|
|
)
|
|
await archive_factory(
|
|
printer.id,
|
|
status="completed",
|
|
print_time_seconds=7200,
|
|
filament_used_grams=100.0,
|
|
)
|
|
|
|
response = await async_client.get("/api/v1/archives/stats")
|
|
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
# Check for actual stats fields
|
|
assert "total_prints" in result
|
|
assert "successful_prints" in result
|
|
|
|
|
|
class TestArchiveDataIntegrity:
|
|
"""Tests for archive data integrity."""
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_archive_linked_to_printer(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Verify archive is properly linked to printer."""
|
|
printer = await printer_factory(name="My Printer")
|
|
archive = await archive_factory(printer.id)
|
|
|
|
response = await async_client.get(f"/api/v1/archives/{archive.id}")
|
|
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
assert result["printer_id"] == printer.id
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_archive_stores_print_data(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Verify archive stores all print data correctly."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
print_name="Test Print",
|
|
filename="test.3mf",
|
|
status="completed",
|
|
filament_type="PLA",
|
|
filament_used_grams=75.5,
|
|
print_time_seconds=5400,
|
|
)
|
|
|
|
response = await async_client.get(f"/api/v1/archives/{archive.id}")
|
|
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
assert result["print_name"] == "Test Print"
|
|
assert result["filename"] == "test.3mf"
|
|
assert result["status"] == "completed"
|
|
assert result["filament_type"] == "PLA"
|
|
assert result["filament_used_grams"] == 75.5
|
|
assert result["print_time_seconds"] == 5400
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_archive_update_persists(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""CRITICAL: Verify archive updates persist."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, notes="Original notes")
|
|
|
|
# Update
|
|
await async_client.patch(f"/api/v1/archives/{archive.id}", json={"notes": "Updated notes", "is_favorite": True})
|
|
|
|
# Verify persistence
|
|
response = await async_client.get(f"/api/v1/archives/{archive.id}")
|
|
result = response.json()
|
|
assert result["notes"] == "Updated notes"
|
|
assert result["is_favorite"] is True
|
|
|
|
|
|
class TestArchiveF3DEndpoints:
|
|
"""Tests for F3D (Fusion 360 design file) attachment endpoints."""
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_archive_response_includes_f3d_path(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Verify f3d_path is included in archive response."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, f3d_path="archives/test/design.f3d")
|
|
|
|
response = await async_client.get(f"/api/v1/archives/{archive.id}")
|
|
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
assert "f3d_path" in result
|
|
assert result["f3d_path"] == "archives/test/design.f3d"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_archive_response_f3d_path_null_when_not_set(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Verify f3d_path is null when no F3D file attached."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id)
|
|
|
|
response = await async_client.get(f"/api/v1/archives/{archive.id}")
|
|
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
assert "f3d_path" in result
|
|
assert result["f3d_path"] is None
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_upload_f3d_to_nonexistent_archive(self, async_client: AsyncClient):
|
|
"""Verify 404 when uploading F3D to non-existent archive."""
|
|
# Create a minimal file-like upload
|
|
files = {"file": ("design.f3d", b"fake f3d content", "application/octet-stream")}
|
|
response = await async_client.post("/api/v1/archives/9999/f3d", files=files)
|
|
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_download_f3d_not_found_when_no_file(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Verify 404 when downloading F3D from archive without F3D file."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id)
|
|
|
|
response = await async_client.get(f"/api/v1/archives/{archive.id}/f3d")
|
|
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_download_f3d_nonexistent_archive(self, async_client: AsyncClient):
|
|
"""Verify 404 when downloading F3D from non-existent archive."""
|
|
response = await async_client.get("/api/v1/archives/9999/f3d")
|
|
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_delete_f3d_nonexistent_archive(self, async_client: AsyncClient):
|
|
"""Verify 404 when deleting F3D from non-existent archive."""
|
|
response = await async_client.delete("/api/v1/archives/9999/f3d")
|
|
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_delete_f3d_when_no_file(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Verify 404 when deleting F3D from archive without F3D file."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id)
|
|
|
|
response = await async_client.delete(f"/api/v1/archives/{archive.id}/f3d")
|
|
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_list_archives_includes_f3d_path(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Verify f3d_path is included in archive list responses."""
|
|
printer = await printer_factory()
|
|
await archive_factory(printer.id, print_name="With F3D", f3d_path="archives/test/design.f3d")
|
|
await archive_factory(printer.id, print_name="Without F3D")
|
|
|
|
response = await async_client.get("/api/v1/archives/")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data) >= 2
|
|
|
|
with_f3d = next((a for a in data if a["print_name"] == "With F3D"), None)
|
|
without_f3d = next((a for a in data if a["print_name"] == "Without F3D"), None)
|
|
|
|
assert with_f3d is not None
|
|
assert with_f3d["f3d_path"] == "archives/test/design.f3d"
|
|
assert without_f3d is not None
|
|
assert without_f3d["f3d_path"] is None
|
|
|
|
# ========================================================================
|
|
# Multi-Plate 3MF endpoints (Issue #93)
|
|
# ========================================================================
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_get_archive_plates_not_found(self, async_client: AsyncClient):
|
|
"""Verify 404 when fetching plates for non-existent archive."""
|
|
response = await async_client.get("/api/v1/archives/999999/plates")
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_get_plate_thumbnail_not_found(self, async_client: AsyncClient):
|
|
"""Verify 404 when fetching plate thumbnail for non-existent archive."""
|
|
response = await async_client.get("/api/v1/archives/999999/plate-thumbnail/1")
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_filament_requirements_not_found(self, async_client: AsyncClient):
|
|
"""Verify filament-requirements returns 404 for non-existent archive."""
|
|
response = await async_client.get("/api/v1/archives/999999/filament-requirements")
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_filament_requirements_with_plate_id_not_found(self, async_client: AsyncClient):
|
|
"""Verify filament-requirements with plate_id returns 404 for non-existent archive."""
|
|
response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1")
|
|
assert response.status_code == 404
|