test_print_log_backfill_migration.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. """Regression test for the PrintLogEntry → PrintArchive backfill migration (#1390).
  2. Reporter IndividualGhost1905 upgraded to 0.2.4.1 (which shipped the per-event
  3. aggregation rewrite from #1378) and saw Quick Stats partially break on old
  4. data:
  5. - Total Filament Cost = 0 (PrintLogEntry.cost was NULL on pre-upgrade rows)
  6. - Time Accuracy empty for pre-upgrade runs (the new query JOINs on
  7. archive_id, which the column-add migration left NULL)
  8. #1378's migration added the columns but didn't backfill anything. This test
  9. pins the backfill that the same `run_migrations` pass now performs:
  10. Step 1: link old log entries to their archive via print_name + printer_id.
  11. Step 2: copy archive.cost / energy_kwh / energy_cost onto the latest
  12. matching log entry per archive (so the sum across archives
  13. reproduces the pre-fix total exactly — pre-#1378, archive.cost
  14. held the LATEST run's value because reprints overwrote it).
  15. Earlier reprints stay with cost = NULL — matching #1378's "first/latest run
  16. writes, the rest stay NULL" convention for new prints, so reruns don't
  17. double-count.
  18. """
  19. from __future__ import annotations
  20. from datetime import datetime, timedelta, timezone
  21. import pytest
  22. from sqlalchemy import text
  23. from sqlalchemy.ext.asyncio import create_async_engine
  24. from backend.app.core.database import run_migrations
  25. @pytest.fixture(autouse=True)
  26. def force_sqlite_dialect(monkeypatch):
  27. """Force the SQLite branch in run_migrations regardless of test env settings."""
  28. from backend.app.core import db_dialect
  29. monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
  30. monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
  31. from backend.app.core import database as database_module
  32. monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
  33. def _register_all_models():
  34. """Import every model so Base.metadata knows the full schema."""
  35. from backend.app.models import ( # noqa: F401
  36. ams_history,
  37. ams_label,
  38. api_key,
  39. archive,
  40. color_catalog,
  41. external_link,
  42. filament,
  43. group,
  44. kprofile_note,
  45. maintenance,
  46. notification,
  47. notification_template,
  48. print_log,
  49. print_queue,
  50. printer,
  51. project,
  52. project_bom,
  53. settings,
  54. slot_preset,
  55. smart_plug,
  56. smart_plug_energy_snapshot,
  57. spool,
  58. spool_assignment,
  59. spool_catalog,
  60. spool_k_profile,
  61. spool_usage_history,
  62. spoolbuddy_device,
  63. user,
  64. user_email_pref,
  65. virtual_printer,
  66. )
  67. @pytest.fixture
  68. async def engine_with_legacy_data():
  69. """Fresh schema + a legacy-shape dataset: two archives, four PrintLogEntry
  70. rows. The cube.3mf archive carries cost+energy (the user's reprinted file);
  71. gear.3mf has neither set. Three matching log entries simulate cube's
  72. reprint history (status: failed → completed → completed). All log entries
  73. start with archive_id and cost = NULL, exactly like the column-add
  74. migration leaves on a pre-#1378 install."""
  75. from sqlalchemy.ext.asyncio import async_sessionmaker
  76. from backend.app.core.database import Base
  77. from backend.app.models.archive import PrintArchive
  78. _register_all_models()
  79. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  80. async with engine.begin() as conn:
  81. await conn.run_sync(Base.metadata.create_all)
  82. SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
  83. async with SessionLocal() as session:
  84. session.add(
  85. PrintArchive(
  86. id=1,
  87. filename="cube.3mf",
  88. file_path="/x/cube.3mf",
  89. file_size=100,
  90. print_name="cube.3mf",
  91. printer_id=1,
  92. cost=4.25,
  93. energy_kwh=0.42,
  94. energy_cost=0.063,
  95. status="completed",
  96. )
  97. )
  98. session.add(
  99. PrintArchive(
  100. id=2,
  101. filename="gear.3mf",
  102. file_path="/x/gear.3mf",
  103. file_size=100,
  104. print_name="gear.3mf",
  105. printer_id=1,
  106. status="completed",
  107. )
  108. )
  109. await session.commit()
  110. async with engine.begin() as conn:
  111. # Three log entries for cube.3mf (two early reprints + a latest run),
  112. # one for gear.3mf. All with archive_id and cost NULL — exactly the
  113. # state the column-add migration leaves on pre-#1378 installs.
  114. base = datetime.now(timezone.utc) - timedelta(days=10)
  115. for i, (delta_days, status, print_name) in enumerate(
  116. [
  117. (0, "failed", "cube.3mf"),
  118. (1, "completed", "cube.3mf"),
  119. (2, "completed", "cube.3mf"), # latest run for cube — must receive backfill
  120. (3, "completed", "gear.3mf"),
  121. ],
  122. start=1,
  123. ):
  124. ts = (base + timedelta(days=delta_days)).isoformat()
  125. await conn.execute(
  126. text("""
  127. INSERT INTO print_log_entries
  128. (id, print_name, printer_id, status, started_at, completed_at,
  129. duration_seconds, filament_used_grams, created_at)
  130. VALUES (:id, :pn, 1, :status, :ts, :ts, 3600, 25.0, :ts)
  131. """),
  132. {"id": i, "pn": print_name, "status": status, "ts": ts},
  133. )
  134. # Force NULL on the columns we want the migration to touch — the
  135. # CREATE TABLE from Base.metadata.create_all already left them NULL,
  136. # but we set explicitly so the fixture's intent is loud.
  137. await conn.execute(
  138. text("UPDATE print_log_entries SET archive_id = NULL, cost = NULL, energy_kwh = NULL, energy_cost = NULL")
  139. )
  140. yield engine
  141. await engine.dispose()
  142. async def test_backfill_links_log_entries_to_their_archive(engine_with_legacy_data):
  143. """All four entries should pick up archive_id after the migration runs."""
  144. async with engine_with_legacy_data.begin() as conn:
  145. await run_migrations(conn)
  146. async with engine_with_legacy_data.connect() as conn:
  147. result = await conn.execute(text("SELECT id, print_name, archive_id FROM print_log_entries ORDER BY id"))
  148. rows = result.all()
  149. assert rows == [
  150. (1, "cube.3mf", 1),
  151. (2, "cube.3mf", 1),
  152. (3, "cube.3mf", 1),
  153. (4, "gear.3mf", 2),
  154. ]
  155. async def test_backfill_copies_cost_and_energy_to_latest_run_only(engine_with_legacy_data):
  156. """Pre-#1378 archive.cost = LAST run's value because reprints overwrote it.
  157. The backfill attributes that cost to the latest matching log entry; earlier
  158. runs stay NULL so summing across runs reproduces sum-of-archive-costs
  159. exactly — what the user saw before the upgrade."""
  160. async with engine_with_legacy_data.begin() as conn:
  161. await run_migrations(conn)
  162. async with engine_with_legacy_data.connect() as conn:
  163. result = await conn.execute(text("SELECT id, cost, energy_kwh, energy_cost FROM print_log_entries ORDER BY id"))
  164. rows = result.all()
  165. # Two earlier cube runs (id 1, 2): cost stays NULL.
  166. assert rows[0] == (1, None, None, None)
  167. assert rows[1] == (2, None, None, None)
  168. # Latest cube run (id 3): receives archive 1's cost / energy.
  169. assert rows[2] == (3, 4.25, 0.42, 0.063)
  170. # gear run (id 4): archive 2 has no cost/energy so log stays NULL too.
  171. assert rows[3] == (4, None, None, None)
  172. async def test_backfill_is_idempotent(engine_with_legacy_data):
  173. """Running the migration twice produces the same state — no double-backfill,
  174. no values pulled off rows the second pass would mistakenly treat as 'new'."""
  175. async with engine_with_legacy_data.begin() as conn:
  176. await run_migrations(conn)
  177. async with engine_with_legacy_data.begin() as conn:
  178. await run_migrations(conn)
  179. async with engine_with_legacy_data.connect() as conn:
  180. result = await conn.execute(text("SELECT id, archive_id, cost FROM print_log_entries ORDER BY id"))
  181. rows = result.all()
  182. assert rows == [
  183. (1, 1, None),
  184. (2, 1, None),
  185. (3, 1, 4.25),
  186. (4, 2, None),
  187. ]
  188. async def test_backfill_skips_archives_with_any_costed_run(engine_with_legacy_data):
  189. """If ANY log entry for an archive already has cost set — e.g. the post-#1378
  190. live write path filled it for a new run — the backfill leaves the entire
  191. archive alone. This is the migration's idempotency anchor: 'cost is
  192. accounted for somewhere on this archive's history' is the signal we use
  193. to decide whether to inject the archive-level value. Backfilling another
  194. row would double-count once the live writes start adding up."""
  195. async with engine_with_legacy_data.begin() as conn:
  196. # Pretend run #1 was written post-fix with its own cost.
  197. await conn.execute(text("UPDATE print_log_entries SET cost = 1.11 WHERE id = 1"))
  198. await run_migrations(conn)
  199. async with engine_with_legacy_data.connect() as conn:
  200. result = await conn.execute(text("SELECT id, cost FROM print_log_entries ORDER BY id"))
  201. rows = result.all()
  202. # Run #1 keeps its live-written cost. The archive already has a costed
  203. # run, so the migration does NOT inject archive.cost onto run #3.
  204. # gear.3mf (archive 2) still has nothing — but archive.cost is NULL
  205. # there too, so the backfill UPDATE would set NULL → NULL anyway, which
  206. # is the desired no-op.
  207. assert dict(rows) == {1: 1.11, 2: None, 3: None, 4: None}