test_settings_dedupe_migration.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. """Regression test for the settings table dedupe + unique-index migration.
  2. Legacy SQLite installs created the `settings` table without a UNIQUE constraint
  3. on `key`. The seed loop's `INSERT OR IGNORE` silently degraded to a plain INSERT
  4. on every restart, duplicating rows. After a handful of restarts, any code path
  5. calling `scalar_one_or_none()` on a `SELECT settings WHERE key = :k` query
  6. (e.g. `is_advanced_auth_enabled`) blew up with `MultipleResultsFound` and 500'd.
  7. `run_migrations` now deletes dup rows (keeping MIN(id) per key) and creates the
  8. missing unique index before the seed loop. This test verifies the fix and its
  9. idempotency on both fresh and legacy schemas.
  10. """
  11. from __future__ import annotations
  12. import pytest
  13. from sqlalchemy import text
  14. from sqlalchemy.exc import IntegrityError
  15. from sqlalchemy.ext.asyncio import create_async_engine
  16. from backend.app.core.database import run_migrations
  17. @pytest.fixture(autouse=True)
  18. def force_sqlite_dialect(monkeypatch):
  19. """Force the SQLite branch in run_migrations regardless of test env settings."""
  20. from backend.app.core import db_dialect
  21. monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
  22. monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
  23. from backend.app.core import database as database_module
  24. monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
  25. def _register_all_models():
  26. """Import every model so Base.metadata knows about them. run_migrations touches
  27. multiple tables, so the full schema has to exist before calling it — mirrors the
  28. pattern in test_ldap_migration.py."""
  29. from backend.app.models import ( # noqa: F401
  30. ams_history,
  31. ams_label,
  32. api_key,
  33. archive,
  34. color_catalog,
  35. external_link,
  36. filament,
  37. group,
  38. kprofile_note,
  39. maintenance,
  40. notification,
  41. notification_template,
  42. print_queue,
  43. printer,
  44. project,
  45. project_bom,
  46. settings,
  47. slot_preset,
  48. smart_plug,
  49. smart_plug_energy_snapshot,
  50. spool,
  51. spool_assignment,
  52. spool_catalog,
  53. spool_k_profile,
  54. spool_usage_history,
  55. spoolbuddy_device,
  56. user,
  57. user_email_pref,
  58. virtual_printer,
  59. )
  60. @pytest.fixture
  61. async def legacy_engine():
  62. """Simulate a pre-UNIQUE install: full schema via create_all, then drop the
  63. settings table and re-create it in the legacy shape (no UNIQUE on key).
  64. This matches real-world upgrades where everything else is modern and only
  65. the settings table carries the stale schema."""
  66. from backend.app.core.database import Base
  67. _register_all_models()
  68. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  69. async with engine.begin() as conn:
  70. await conn.run_sync(Base.metadata.create_all)
  71. await conn.execute(text("DROP TABLE settings"))
  72. await conn.execute(
  73. text("""
  74. CREATE TABLE settings (
  75. id INTEGER PRIMARY KEY,
  76. key TEXT,
  77. value TEXT,
  78. created_at TEXT,
  79. updated_at TEXT
  80. )
  81. """)
  82. )
  83. yield engine
  84. await engine.dispose()
  85. @pytest.fixture
  86. async def fresh_engine():
  87. """Simulate a fresh install: every table created from SQLAlchemy models, which
  88. DOES emit the unique index on settings.key. Verifies the migration is a no-op."""
  89. from backend.app.core.database import Base
  90. _register_all_models()
  91. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  92. async with engine.begin() as conn:
  93. await conn.run_sync(Base.metadata.create_all)
  94. yield engine
  95. await engine.dispose()
  96. # -----------------------------------------------------------------------------
  97. # Legacy schema tests
  98. # -----------------------------------------------------------------------------
  99. async def test_legacy_schema_allows_duplicate_keys_before_migration(legacy_engine):
  100. """Sanity check: the legacy schema really does permit duplicates — protects
  101. the migration test below from becoming a false-positive if the fixture drifts."""
  102. async with legacy_engine.begin() as conn:
  103. await conn.execute(text("INSERT INTO settings (key, value) VALUES ('advanced_auth_enabled', 'false')"))
  104. await conn.execute(text("INSERT INTO settings (key, value) VALUES ('advanced_auth_enabled', 'false')"))
  105. result = await conn.execute(text("SELECT COUNT(*) FROM settings WHERE key = 'advanced_auth_enabled'"))
  106. assert result.scalar_one() == 2
  107. async def test_migration_dedupes_and_adds_unique_index(legacy_engine):
  108. """Given a legacy DB with duplicate rows for the same key, run_migrations
  109. should (a) delete duplicates keeping the lowest id, (b) add the unique index,
  110. (c) make future duplicate inserts fail with IntegrityError."""
  111. # Seed: two duplicate rows for the same key, with distinguishable values.
  112. async with legacy_engine.begin() as conn:
  113. await conn.execute(text("INSERT INTO settings (id, key, value) VALUES (1, 'advanced_auth_enabled', 'old')"))
  114. await conn.execute(text("INSERT INTO settings (id, key, value) VALUES (2, 'advanced_auth_enabled', 'new')"))
  115. # Also seed an unrelated key that should survive untouched.
  116. await conn.execute(text("INSERT INTO settings (id, key, value) VALUES (3, 'other_key', 'keep_me')"))
  117. async with legacy_engine.begin() as conn:
  118. await run_migrations(conn)
  119. async with legacy_engine.begin() as conn:
  120. # Only the MIN(id) row for the duplicated key remains.
  121. rows = (await conn.execute(text("SELECT id, value FROM settings WHERE key = 'advanced_auth_enabled'"))).all()
  122. assert len(rows) == 1
  123. assert rows[0].id == 1
  124. assert rows[0].value == "old"
  125. # Untouched key still present.
  126. other = (await conn.execute(text("SELECT value FROM settings WHERE key = 'other_key'"))).scalar_one()
  127. assert other == "keep_me"
  128. # Unique constraint is now enforced — inserting a duplicate fails.
  129. with pytest.raises(IntegrityError):
  130. await conn.execute(text("INSERT INTO settings (key, value) VALUES ('advanced_auth_enabled', 'x')"))
  131. async def test_migration_is_idempotent_on_already_clean_legacy(legacy_engine):
  132. """Running the migration twice must not crash — the second run finds no
  133. duplicates and the CREATE UNIQUE INDEX IF NOT EXISTS is a no-op."""
  134. async with legacy_engine.begin() as conn:
  135. await conn.execute(text("INSERT INTO settings (key, value) VALUES ('k', 'v')"))
  136. async with legacy_engine.begin() as conn:
  137. await run_migrations(conn)
  138. async with legacy_engine.begin() as conn:
  139. await run_migrations(conn)
  140. async with legacy_engine.begin() as conn:
  141. count = (await conn.execute(text("SELECT COUNT(*) FROM settings WHERE key = 'k'"))).scalar_one()
  142. assert count == 1
  143. # -----------------------------------------------------------------------------
  144. # Fresh-install test — migration must be a safe no-op
  145. # -----------------------------------------------------------------------------
  146. async def test_migration_is_noop_on_fresh_install(fresh_engine):
  147. """Fresh installs get the unique index from `create_all`. Running the
  148. migration must not crash and must not alter the schema."""
  149. async with fresh_engine.begin() as conn:
  150. await run_migrations(conn)
  151. async with fresh_engine.begin() as conn:
  152. # Unique constraint still present — duplicate insert fails.
  153. await conn.execute(text("INSERT INTO settings (key, value) VALUES ('k', 'v1')"))
  154. with pytest.raises(IntegrityError):
  155. await conn.execute(text("INSERT INTO settings (key, value) VALUES ('k', 'v2')"))