test_location_migration.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. """Regression tests for storage-location migration backfill (#1004).
  2. Legacy installs may have free-text storage_location values that differ only
  3. by case. The backfill must collapse them to one catalog row and stay
  4. idempotent across restarts.
  5. """
  6. from __future__ import annotations
  7. import pytest
  8. from sqlalchemy import text
  9. from sqlalchemy.ext.asyncio import create_async_engine
  10. from backend.app.core.database import run_migrations
  11. @pytest.fixture(autouse=True)
  12. def force_sqlite_dialect(monkeypatch):
  13. from backend.app.core import db_dialect
  14. monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
  15. monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
  16. from backend.app.core import database as database_module
  17. monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
  18. def _register_all_models():
  19. import backend.app.models # noqa: F401
  20. from backend.app.models import ( # noqa: F401
  21. external_link,
  22. location,
  23. print_log,
  24. print_queue,
  25. project_bom,
  26. slot_preset,
  27. spoolman_k_profile,
  28. spoolman_slot_assignment,
  29. virtual_printer,
  30. )
  31. @pytest.fixture
  32. async def engine_with_case_variant_spools():
  33. from backend.app.core.database import Base
  34. _register_all_models()
  35. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  36. async with engine.begin() as conn:
  37. await conn.run_sync(Base.metadata.create_all)
  38. await conn.execute(text("DELETE FROM locations"))
  39. await conn.execute(
  40. text(
  41. """
  42. INSERT INTO spool (
  43. material, storage_location, label_weight, core_weight,
  44. weight_used, weight_used_baseline, weight_locked
  45. )
  46. VALUES ('PLA', 'Drybox 1', 1000, 250, 0, 0, 0),
  47. ('PETG', 'DRYBOX 1', 1000, 250, 0, 0, 0)
  48. """
  49. )
  50. )
  51. yield engine
  52. await engine.dispose()
  53. async def test_backfill_collapses_case_variant_storage_locations(engine_with_case_variant_spools):
  54. async with engine_with_case_variant_spools.begin() as conn:
  55. await run_migrations(conn)
  56. async with engine_with_case_variant_spools.connect() as conn:
  57. loc_rows = (await conn.execute(text("SELECT id, name, name_key FROM locations ORDER BY id"))).all()
  58. spool_rows = (await conn.execute(text("SELECT id, storage_location, location_id FROM spool ORDER BY id"))).all()
  59. assert len(loc_rows) == 1
  60. assert loc_rows[0].name_key == "drybox 1"
  61. location_id = loc_rows[0].id
  62. assert all(row.location_id == location_id for row in spool_rows)
  63. async def test_backfill_is_idempotent_with_existing_locations(engine_with_case_variant_spools):
  64. async with engine_with_case_variant_spools.begin() as conn:
  65. await run_migrations(conn)
  66. async with engine_with_case_variant_spools.begin() as conn:
  67. await run_migrations(conn)
  68. async with engine_with_case_variant_spools.connect() as conn:
  69. loc_count = (await conn.execute(text("SELECT COUNT(*) FROM locations"))).scalar_one()
  70. linked = (await conn.execute(text("SELECT COUNT(*) FROM spool WHERE location_id IS NOT NULL"))).scalar_one()
  71. assert loc_count == 1
  72. assert linked == 2
  73. @pytest.fixture
  74. async def engine_with_null_storage_location():
  75. """A spool with NULL storage_location must NOT produce a phantom location row
  76. or get linked to anything — it stays NULL on both fields."""
  77. from backend.app.core.database import Base
  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. await conn.execute(text("DELETE FROM locations"))
  83. await conn.execute(
  84. text(
  85. """
  86. INSERT INTO spool (
  87. material, storage_location, label_weight, core_weight,
  88. weight_used, weight_used_baseline, weight_locked
  89. )
  90. VALUES ('PLA', NULL, 1000, 250, 0, 0, 0),
  91. ('PETG', ' ', 1000, 250, 0, 0, 0),
  92. ('TPU', 'Real Shelf', 1000, 250, 0, 0, 0)
  93. """
  94. )
  95. )
  96. yield engine
  97. await engine.dispose()
  98. async def test_backfill_skips_null_and_whitespace_storage_location(
  99. engine_with_null_storage_location,
  100. ):
  101. """NULL / whitespace-only `storage_location` rows must NOT create catalog
  102. rows; only the 'Real Shelf' value gets a location row + spool link."""
  103. async with engine_with_null_storage_location.begin() as conn:
  104. await run_migrations(conn)
  105. async with engine_with_null_storage_location.connect() as conn:
  106. loc_rows = (await conn.execute(text("SELECT name FROM locations"))).all()
  107. unlinked = (
  108. await conn.execute(text("SELECT material FROM spool WHERE location_id IS NULL ORDER BY material"))
  109. ).all()
  110. # Only the row with a real storage_location should be in the catalog.
  111. assert [r.name for r in loc_rows] == ["Real Shelf"]
  112. # The NULL and whitespace-only spools stay unlinked (no phantom row).
  113. assert [r.material for r in unlinked] == ["PETG", "PLA"]
  114. @pytest.fixture
  115. async def engine_with_legacy_null_name_key_location():
  116. """Simulate a legacy install where a `locations` row was manually inserted
  117. BEFORE the name_key column existed. The migration must backfill the
  118. legacy row's name_key BEFORE the dedup INSERT, so the spool-link UPDATE
  119. can join on the new key (#1505 review IMPORTANT 11)."""
  120. from backend.app.core.database import Base
  121. _register_all_models()
  122. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  123. async with engine.begin() as conn:
  124. await conn.run_sync(Base.metadata.create_all)
  125. # Drop the model-shaped locations table (which has NOT NULL on
  126. # name_key) and recreate it in its pre-migration shape: no name_key
  127. # column at all, mirroring a real upgrade from a Bambuddy version
  128. # that predates this feature. The migration's idempotent ALTER TABLE
  129. # is what adds the column without a NOT NULL constraint, so the
  130. # legacy row can legally have NULL until the new backfill UPDATE
  131. # runs.
  132. await conn.execute(text("DROP TABLE locations"))
  133. await conn.execute(
  134. text(
  135. """
  136. CREATE TABLE locations (
  137. id INTEGER PRIMARY KEY AUTOINCREMENT,
  138. name VARCHAR(255) NOT NULL UNIQUE,
  139. identifier VARCHAR(100),
  140. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  141. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  142. )
  143. """
  144. )
  145. )
  146. await conn.execute(text("INSERT INTO locations (name) VALUES ('Drybox 1')"))
  147. await conn.execute(
  148. text(
  149. """
  150. INSERT INTO spool (
  151. material, storage_location, label_weight, core_weight,
  152. weight_used, weight_used_baseline, weight_locked
  153. )
  154. VALUES ('PLA', 'Drybox 1', 1000, 250, 0, 0, 0)
  155. """
  156. )
  157. )
  158. yield engine
  159. await engine.dispose()
  160. async def test_backfill_links_spool_to_legacy_null_name_key_location(
  161. engine_with_legacy_null_name_key_location,
  162. ):
  163. async with engine_with_legacy_null_name_key_location.begin() as conn:
  164. await run_migrations(conn)
  165. async with engine_with_legacy_null_name_key_location.connect() as conn:
  166. loc_rows = (await conn.execute(text("SELECT id, name, name_key FROM locations"))).all()
  167. spool_rows = (await conn.execute(text("SELECT location_id FROM spool"))).all()
  168. # Exactly one location row (the pre-existing legacy one); its name_key
  169. # got backfilled by the FIRST step of the migration.
  170. assert len(loc_rows) == 1
  171. assert loc_rows[0].name_key == "drybox 1"
  172. # The spool got linked to that legacy row — under the old ordering it
  173. # would have been left with `location_id IS NULL`.
  174. assert spool_rows[0].location_id == loc_rows[0].id