test_print_log.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. """Unit tests for print log service and schema."""
  2. from datetime import datetime, timedelta
  3. from unittest.mock import AsyncMock, MagicMock
  4. import pytest
  5. from backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogResponse
  6. from backend.app.services.print_log import write_log_entry
  7. class TestPrintLogEntrySchema:
  8. """Test PrintLogEntrySchema validation."""
  9. def test_minimal_entry(self):
  10. """Schema accepts minimal required fields."""
  11. entry = PrintLogEntrySchema(
  12. id=1,
  13. status="completed",
  14. created_at=datetime(2024, 1, 15, 10, 30, 0),
  15. )
  16. assert entry.id == 1
  17. assert entry.status == "completed"
  18. assert entry.print_name is None
  19. assert entry.printer_name is None
  20. assert entry.duration_seconds is None
  21. def test_full_entry(self):
  22. """Schema accepts all fields."""
  23. started = datetime(2024, 1, 15, 10, 0, 0)
  24. completed = datetime(2024, 1, 15, 12, 30, 0)
  25. entry = PrintLogEntrySchema(
  26. id=42,
  27. print_name="Benchy",
  28. printer_name="X1C-01",
  29. printer_id=3,
  30. status="completed",
  31. started_at=started,
  32. completed_at=completed,
  33. duration_seconds=9000,
  34. filament_type="PLA",
  35. filament_color="#FF5500",
  36. filament_used_grams=15.2,
  37. thumbnail_path="archives/3/20240115_benchy/thumbnail.png",
  38. created_by_username="admin",
  39. created_at=datetime(2024, 1, 15, 12, 30, 0),
  40. )
  41. assert entry.print_name == "Benchy"
  42. assert entry.printer_name == "X1C-01"
  43. assert entry.filament_used_grams == 15.2
  44. assert entry.created_by_username == "admin"
  45. def test_failed_status(self):
  46. """Schema accepts various status values."""
  47. for status in ("completed", "failed", "stopped", "cancelled", "skipped"):
  48. entry = PrintLogEntrySchema(id=1, status=status, created_at=datetime.now())
  49. assert entry.status == status
  50. class TestPrintLogResponse:
  51. """Test PrintLogResponse pagination wrapper."""
  52. def test_empty_response(self):
  53. """Empty response with zero total."""
  54. resp = PrintLogResponse(items=[], total=0)
  55. assert len(resp.items) == 0
  56. assert resp.total == 0
  57. def test_paginated_response(self):
  58. """Response with items and total count > items count."""
  59. items = [PrintLogEntrySchema(id=i, status="completed", created_at=datetime.now()) for i in range(3)]
  60. resp = PrintLogResponse(items=items, total=100)
  61. assert len(resp.items) == 3
  62. assert resp.total == 100
  63. class TestWriteLogEntry:
  64. """Test the write_log_entry service function (logic only, no DB)."""
  65. def test_duration_calculation(self):
  66. """Duration is computed from started_at and completed_at."""
  67. started = datetime(2024, 1, 15, 10, 0, 0)
  68. completed = started + timedelta(hours=2, minutes=30)
  69. # Simulating the duration calculation from write_log_entry
  70. duration = int((completed - started).total_seconds())
  71. assert duration == 9000 # 2.5 hours = 9000 seconds
  72. def test_duration_none_when_missing_times(self):
  73. """Duration is None when started_at or completed_at is missing."""
  74. started = datetime(2024, 1, 15, 10, 0, 0)
  75. completed_at = None
  76. started_at = None
  77. completed = datetime.now()
  78. # No completed_at
  79. duration = None
  80. if started and completed_at:
  81. duration = int((completed_at - started).total_seconds())
  82. assert duration is None
  83. # No started_at
  84. duration = None
  85. if started_at and completed:
  86. duration = int((completed - started_at).total_seconds())
  87. assert duration is None
  88. class TestWriteLogEntryReconciledDuration:
  89. """write_log_entry duration handling for reconciled (synthetic) completions (#2592).
  90. A reconciled abort closes out a stale ``status="printing"`` archive at
  91. reconnect; its real end time is unknown, so ``completed_at - started_at``
  92. would bank the whole disconnect gap as print time. Those entries must log
  93. 0, while genuine prints (including >24h ones) keep their real duration.
  94. """
  95. @staticmethod
  96. async def _write(**kwargs):
  97. db = MagicMock()
  98. db.flush = AsyncMock()
  99. return await write_log_entry(db, **kwargs)
  100. @pytest.mark.asyncio
  101. async def test_reconciled_logs_zero_despite_multiday_gap(self):
  102. started = datetime(2026, 7, 15, 10, 0, 0)
  103. completed = started + timedelta(days=2, hours=4) # the reconnect moment, not the real end
  104. entry = await self._write(status="aborted", started_at=started, completed_at=completed, reconciled=True)
  105. assert entry.duration_seconds == 0
  106. @pytest.mark.asyncio
  107. async def test_reconciled_logs_zero_even_without_timestamps(self):
  108. entry = await self._write(status="aborted", reconciled=True)
  109. assert entry.duration_seconds == 0
  110. @pytest.mark.asyncio
  111. async def test_genuine_long_print_retains_full_duration(self):
  112. """A legitimate >24h print keeps its real duration — no cap, no zeroing."""
  113. started = datetime(2026, 7, 15, 10, 0, 0)
  114. completed = started + timedelta(hours=30)
  115. entry = await self._write(status="completed", started_at=started, completed_at=completed)
  116. assert entry.duration_seconds == 30 * 3600
  117. @pytest.mark.asyncio
  118. async def test_non_reconciled_missing_times_is_none(self):
  119. entry = await self._write(status="completed", started_at=datetime(2026, 7, 15, 10, 0, 0))
  120. assert entry.duration_seconds is None
  121. class TestSchemaValidatesFromOrmRow:
  122. """#2636: the Print Log's cost and energy columns read empty for every
  123. run because both routes built the response field-by-field and simply
  124. never mentioned ``cost`` / ``energy_kwh`` / ``energy_cost``. Pydantic
  125. filled the gap with each field's default, so a dropped field looked
  126. exactly like a NULL column on the wire — no error, no log line. The
  127. same trap had already eaten ``failure_reason`` once (#1687 part 4).
  128. Validating from the ORM row is what removes the chance to forget one, so
  129. these tests pin the mechanism rather than any particular field list.
  130. """
  131. @staticmethod
  132. def _row(**overrides):
  133. row = MagicMock()
  134. row.id = 7
  135. row.archive_id = 3
  136. row.print_name = "Benchy"
  137. row.printer_name = "X1C-01"
  138. row.printer_id = 1
  139. row.status = "completed"
  140. row.started_at = datetime(2026, 7, 24, 18, 35, 0)
  141. row.completed_at = datetime(2026, 7, 24, 19, 24, 0)
  142. row.duration_seconds = 2940
  143. row.filament_type = "PLA"
  144. row.filament_color = "#000000"
  145. row.filament_used_grams = 15.5
  146. row.cost = 0.42
  147. row.energy_kwh = 0.31
  148. row.energy_cost = 0.09
  149. # Non-null so `test_every_declared_field_is_carried` can assert that
  150. # nothing falls back to its default.
  151. row.failure_reason = "warping"
  152. row.thumbnail_path = "archives/1/x/thumbnail.png"
  153. row.created_by_id = 2
  154. row.created_by_username = "martin"
  155. row.created_at = datetime(2026, 7, 24, 18, 35, 0)
  156. for k, v in overrides.items():
  157. setattr(row, k, v)
  158. return row
  159. def test_money_and_energy_survive_the_round_trip(self):
  160. entry = PrintLogEntrySchema.model_validate(self._row())
  161. assert entry.cost == 0.42
  162. assert entry.energy_kwh == 0.31
  163. assert entry.energy_cost == 0.09
  164. assert entry.filament_used_grams == 15.5
  165. def test_every_declared_field_is_carried(self):
  166. """Nothing on the schema may come back as its default when the row
  167. has a value — that is the whole failure mode, generalised."""
  168. entry = PrintLogEntrySchema.model_validate(self._row())
  169. for name in PrintLogEntrySchema.model_fields:
  170. assert getattr(entry, name) is not None, f"{name} was dropped in serialisation"
  171. def test_a_genuinely_null_column_stays_null(self):
  172. """The counterpart: energy is written by a background task after the
  173. row, so a just-finished print really has none. That must read as
  174. None, not as a fabricated zero."""
  175. entry = PrintLogEntrySchema.model_validate(self._row(energy_kwh=None, energy_cost=None))
  176. assert entry.energy_kwh is None
  177. assert entry.energy_cost is None
  178. assert entry.cost == 0.42