test_spool_usage_outlives_archive.py 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. """Deleting an archive a spool was used for keeps the spool's usage record.
  2. The usage row pointed at the archive with no delete rule: on PostgreSQL every
  3. hard delete of such an archive (purge from statistics, or a printer delete
  4. with its archives) failed on the foreign key with a server error. The row is
  5. the spool's consumption record, so it stays and only loses the link.
  6. """
  7. from __future__ import annotations
  8. import pytest
  9. from httpx import AsyncClient
  10. from sqlalchemy import select
  11. from backend.app.models.spool import Spool
  12. from backend.app.models.spool_usage_history import SpoolUsageHistory
  13. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  14. async def _used_archive(db_session, archive_factory, printer_id: int) -> tuple[int, int]:
  15. archive = await archive_factory(printer_id)
  16. spool = Spool(material="PLA")
  17. db_session.add(spool)
  18. await db_session.flush()
  19. usage = SpoolUsageHistory(spool_id=spool.id, printer_id=printer_id, archive_id=archive.id, weight_used=10, cost=0.5)
  20. db_session.add(usage)
  21. await db_session.commit()
  22. return archive.id, usage.id
  23. async def _usage(db_session, usage_id: int):
  24. db_session.expire_all()
  25. return (
  26. await db_session.execute(
  27. select(SpoolUsageHistory.archive_id, SpoolUsageHistory.weight_used).where(SpoolUsageHistory.id == usage_id)
  28. )
  29. ).one_or_none()
  30. async def test_purging_an_archive_keeps_the_spool_usage(
  31. async_client: AsyncClient, db_session, printer_factory, archive_factory
  32. ):
  33. printer = await printer_factory()
  34. archive_id, usage_id = await _used_archive(db_session, archive_factory, printer.id)
  35. response = await async_client.delete(f"/api/v1/archives/{archive_id}?purge_stats=true")
  36. assert response.status_code == 200, response.text
  37. assert tuple(await _usage(db_session, usage_id)) == (None, 10)
  38. async def test_deleting_a_printer_with_its_archives_keeps_the_spool_usage(
  39. async_client: AsyncClient, db_session, printer_factory, archive_factory
  40. ):
  41. printer = await printer_factory()
  42. _, usage_id = await _used_archive(db_session, archive_factory, printer.id)
  43. response = await async_client.delete(f"/api/v1/printers/{printer.id}?delete_archives=true")
  44. assert response.status_code == 200, response.text
  45. row = await _usage(db_session, usage_id)
  46. assert row is not None and row[0] is None
  47. async def _unlinked_usage(db_session, print_name: str, cost: float, created_at) -> None:
  48. spool = Spool(material="PLA")
  49. db_session.add(spool)
  50. await db_session.flush()
  51. db_session.add(
  52. SpoolUsageHistory(
  53. spool_id=spool.id, print_name=print_name, archive_id=None, weight_used=10, cost=cost, created_at=created_at
  54. )
  55. )
  56. await db_session.commit()
  57. async def test_recalculating_costs_does_not_match_unlinked_usage_by_name(
  58. async_client: AsyncClient, db_session, printer_factory, archive_factory
  59. ):
  60. # A deleted archive's usage keeps its print name but loses its link; usage
  61. # without a link is not charged to an archive by name, so the archive gets
  62. # its estimate from the catalog price.
  63. from datetime import datetime, timedelta
  64. from backend.app.models.archive import PrintArchive
  65. printer = await printer_factory()
  66. printed = datetime(2026, 9, 1, 12, 0)
  67. archive = await archive_factory(
  68. printer.id, print_name="Benchy", created_at=printed, filament_used_grams=100, filament_type="PLA"
  69. )
  70. archive_id = archive.id
  71. await _unlinked_usage(db_session, "Benchy", 9.99, printed + timedelta(hours=1))
  72. response = await async_client.post("/api/v1/archives/recalculate-costs")
  73. assert response.status_code == 200, response.text
  74. db_session.expire_all()
  75. cost = (await db_session.execute(select(PrintArchive.cost).where(PrintArchive.id == archive_id))).scalar()
  76. # 100 g at the default 25.00 per kg, not the unlinked 9.99.
  77. assert cost == 2.5