import pytest from httpx import AsyncClient from sqlalchemy import select from backend.app.models.archive import PrintArchive from backend.app.models.spool import Spool from backend.app.models.spool_assignment import SpoolAssignment from backend.app.models.spool_usage_history import SpoolUsageHistory @pytest.fixture(autouse=True) def cleanup_test_archive_files(): yield import glob import os # Remove any test archive files created in archives/test/ for f in glob.glob("archives/test/test_print*.3mf"): try: os.remove(f) except Exception: pass """Integration tests for cost tracking in archives and statistics. Tests the full flow of cost tracking from usage to statistics: - Archive cost field populated correctly - Statistics endpoint aggregates costs - Completed vs failed prints cost handling """ class TestArchiveCostTracking: """Tests for cost field in PrintArchive.""" @pytest.mark.asyncio @pytest.mark.integration async def test_archive_has_cost_field( self, async_client: AsyncClient, archive_factory, printer_factory, db_session ): # Verify PrintArchive includes cost field in response. printer = await printer_factory() archive = await archive_factory( printer.id, print_name="Test Archive", status="completed", cost=5.50, # Set a cost ) response = await async_client.get(f"/api/v1/archives/{archive.id}") assert response.status_code == 200 result = response.json() assert "cost" in result assert result["cost"] == 5.50 await db_session.rollback() @pytest.mark.asyncio @pytest.mark.integration async def test_archive_cost_null_when_not_set( self, async_client: AsyncClient, archive_factory, printer_factory, db_session ): # Verify cost is null when not set. printer = await printer_factory() archive = await archive_factory( printer.id, print_name="Test Archive", status="completed", # cost not set ) response = await async_client.get(f"/api/v1/archives/{archive.id}") assert response.status_code == 200 result = response.json() assert result["cost"] is None or result["cost"] == 0 await db_session.rollback() class TestStatisticsCostAggregation: """Tests for cost aggregation in statistics endpoint.""" @pytest.mark.asyncio @pytest.mark.integration async def test_statistics_includes_total_cost( self, async_client: AsyncClient, archive_factory, printer_factory, db_session ): # Verify statistics endpoint includes total_cost field. printer = await printer_factory() # Create archives with costs await archive_factory( printer.id, status="completed", cost=2.50, filament_used_grams=100.0, ) await archive_factory( printer.id, status="completed", cost=3.75, filament_used_grams=150.0, ) response = await async_client.get("/api/v1/archives/stats") assert response.status_code == 200 result = response.json() assert "total_cost" in result assert result["total_cost"] == 6.25 await db_session.rollback() @pytest.mark.asyncio @pytest.mark.integration async def test_statistics_aggregates_costs_correctly( self, async_client: AsyncClient, archive_factory, printer_factory, db_session ): # Verify statistics correctly sums costs from all archives. printer = await printer_factory() # Create multiple archives with different costs costs = [1.25, 2.50, 0.75, 5.00, 0.50] for cost in costs: await archive_factory( printer.id, status="completed", cost=cost, filament_used_grams=50.0, ) response = await async_client.get("/api/v1/archives/stats") assert response.status_code == 200 result = response.json() expected_total = sum(costs) assert result["total_cost"] == expected_total await db_session.rollback() @pytest.mark.asyncio @pytest.mark.integration async def test_statistics_handles_null_costs( self, async_client: AsyncClient, archive_factory, printer_factory, db_session ): # Verify statistics handles archives with null costs gracefully. printer = await printer_factory() # Mix of archives with and without costs await archive_factory(printer.id, status="completed", cost=2.50) await archive_factory(printer.id, status="completed", cost=None) await archive_factory(printer.id, status="completed", cost=1.75) await archive_factory(printer.id, status="completed") # No cost field response = await async_client.get("/api/v1/archives/stats") assert response.status_code == 200 result = response.json() # Should sum only non-null costs assert result["total_cost"] == 4.25 await db_session.rollback() @pytest.mark.asyncio @pytest.mark.integration async def test_statistics_includes_failed_print_costs( self, async_client: AsyncClient, archive_factory, printer_factory, db_session ): # Verify failed prints with costs are included in statistics. printer = await printer_factory() await archive_factory(printer.id, status="completed", cost=5.00) await archive_factory(printer.id, status="failed", cost=2.50) # Failed but has cost await archive_factory(printer.id, status="cancelled", cost=1.00) response = await async_client.get("/api/v1/archives/stats") assert response.status_code == 200 result = response.json() # All prints should contribute to total cost assert result["total_cost"] == 8.50 await db_session.rollback() @pytest.mark.asyncio @pytest.mark.integration async def test_statistics_zero_cost_when_no_archives(self, async_client: AsyncClient): """Verify total_cost is 0 when no archives exist.""" response = await async_client.get("/api/v1/archives/stats") assert response.status_code == 200 result = response.json() assert result["total_cost"] == 0.0 class TestSpoolCostPersistence: """Tests for spool cost_per_kg field.""" @pytest.mark.asyncio @pytest.mark.integration async def test_spool_cost_fields_persist(self, async_client: AsyncClient, db_session): # Verify cost_per_kg is saved and retrieved. # Create a spool with cost spool_data = { "material": "PLA", "brand": "TestBrand", "label_weight": 1000, "core_weight": 250, "cost_per_kg": 25.50, } create_response = await async_client.post("/api/v1/inventory/spools", json=spool_data) assert create_response.status_code == 200 spool_id = create_response.json()["id"] # Retrieve and verify get_response = await async_client.get(f"/api/v1/inventory/spools/{spool_id}") assert get_response.status_code == 200 result = get_response.json() assert result["cost_per_kg"] == 25.50 await db_session.rollback() @pytest.mark.asyncio @pytest.mark.integration async def test_spool_update_cost_fields(self, async_client: AsyncClient, db_session): # Verify cost fields can be updated. # Create spool without cost spool_data = { "material": "PETG", "brand": "TestBrand", "label_weight": 1000, "core_weight": 250, } create_response = await async_client.post("/api/v1/inventory/spools", json=spool_data) assert create_response.status_code == 200 spool_id = create_response.json()["id"] # Update with cost update_data = { "cost_per_kg": 30.00, } update_response = await async_client.patch(f"/api/v1/inventory/spools/{spool_id}", json=update_data) assert update_response.status_code == 200 result = update_response.json() assert result["cost_per_kg"] == 30.00 await db_session.rollback() @pytest.mark.asyncio @pytest.mark.integration async def test_spool_cost_null_by_default(self, async_client: AsyncClient, db_session): # Verify cost_per_kg defaults to null when not provided. spool_data = { "material": "ABS", "label_weight": 1000, "core_weight": 250, } create_response = await async_client.post("/api/v1/inventory/spools", json=spool_data) assert create_response.status_code == 200 result = create_response.json() assert result["cost_per_kg"] is None await db_session.rollback() class TestCostCalculationScenarios: """End-to-end tests for various cost calculation scenarios.""" @pytest.mark.asyncio @pytest.mark.integration async def test_cost_with_multiple_colors(self, async_client: AsyncClient, printer_factory, db_session): # Verify cost tracking works for multi-color prints. # Create two spools with different costs spool1_data = { "material": "ABS", "brand": "TestBrand", "label_weight": 1000, "core_weight": 250, "cost_per_kg": 20.00, } spool2_data = { "material": "PLA", "label_weight": 1000, "core_weight": 250, "cost_per_kg": 25.00, } spool1_response = await async_client.post("/api/v1/inventory/spools", json=spool1_data) spool2_response = await async_client.post("/api/v1/inventory/spools", json=spool2_data) assert spool1_response.status_code == 200 assert spool2_response.status_code == 200 # Verify spools created with correct costs assert spool1_response.json()["cost_per_kg"] == 20.00 assert spool2_response.json()["cost_per_kg"] == 25.00 await db_session.rollback() @pytest.mark.asyncio @pytest.mark.integration async def test_cost_precision(self, async_client: AsyncClient, db_session): # Verify cost calculations maintain proper precision. # Create spool with specific cost spool_data = { "material": "PLA", "brand": "TestBrand", "label_weight": 1000, "core_weight": 250, "cost_per_kg": 19.99, # Specific price } response = await async_client.post("/api/v1/inventory/spools", json=spool_data) assert response.status_code == 200 result = response.json() # Verify precision is maintained assert result["cost_per_kg"] == 19.99 await db_session.rollback() @pytest.mark.asyncio @pytest.mark.integration async def test_archive_cost_with_archive_id_and_print_name( self, async_client, archive_factory, printer_factory, db_session ): """Recalculation uses usage linked by archive_id; unlinked usage is not matched by name. Matching unlinked usage by print name charged a deleted print's (or every earlier run's) spool cost to each archive of that name, so an archive without linked usage gets the estimate from the filament price. """ from backend.app.models.spool import Spool from backend.app.models.spool_usage_history import SpoolUsageHistory printer = await printer_factory() # Create spools and commit spool_new = Spool( material="PLA", brand="BrandA", label_weight=1000, core_weight=250, cost_per_kg=20.0, ) spool_old = Spool( material="ABS", brand="BrandB", label_weight=1000, core_weight=250, cost_per_kg=15.0, ) db_session.add_all([spool_new, spool_old]) await db_session.commit() await db_session.refresh(spool_new) await db_session.refresh(spool_old) # Create archive with new SpoolUsageHistory (archive_id set). # filament_used_grams matches the tracked weight so the #1344 default- # rate top-up for untracked filament doesn't apply -- this test pins # the query routing, not the top-up branch. archive_new = await archive_factory( printer.id, print_name="UniquePrint", status="completed", cost=None, filament_used_grams=20.0, ) history_new = SpoolUsageHistory( spool_id=spool_new.id, printer_id=printer.id, print_name="UniquePrint", weight_used=20.0, percent_used=20, status="completed", cost=0.50, archive_id=archive_new.id, ) db_session.add(history_new) # Create archive with old SpoolUsageHistory (archive_id NULL — legacy record) archive_old = await archive_factory( printer.id, print_name="LegacyPrint", status="completed", cost=None, ) archive_old.filament_used_grams = 30.0 await db_session.commit() history_old = SpoolUsageHistory( spool_id=spool_old.id, printer_id=printer.id, print_name="LegacyPrint", weight_used=30.0, percent_used=30, status="completed", cost=0.45, archive_id=None, ) db_session.add(history_old) await db_session.commit() # Recalculate costs for all archives recalc_response = await async_client.post("/api/v1/archives/recalculate-costs") assert recalc_response.status_code == 200 assert recalc_response.json()["updated"] >= 1 # Verify archive_new cost from archive_id-linked SpoolUsageHistory response_new = await async_client.get(f"/api/v1/archives/{archive_new.id}") assert response_new.status_code == 200 assert response_new.json()["cost"] == 0.50 # archive_old: the unlinked 0.45 is not used; 30 g of PLA at the default # 25.00 per kg instead. response_old = await async_client.get(f"/api/v1/archives/{archive_old.id}") assert response_old.status_code == 200 assert response_old.json()["cost"] == 0.75 await db_session.rollback()