mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
194 lines
7.7 KiB
Python
194 lines
7.7 KiB
Python
"""Regression test for the settings table dedupe + unique-index migration.
|
|
|
|
Legacy SQLite installs created the `settings` table without a UNIQUE constraint
|
|
on `key`. The seed loop's `INSERT OR IGNORE` silently degraded to a plain INSERT
|
|
on every restart, duplicating rows. After a handful of restarts, any code path
|
|
calling `scalar_one_or_none()` on a `SELECT settings WHERE key = :k` query
|
|
(e.g. `is_advanced_auth_enabled`) blew up with `MultipleResultsFound` and 500'd.
|
|
|
|
`run_migrations` now deletes dup rows (keeping MIN(id) per key) and creates the
|
|
missing unique index before the seed loop. This test verifies the fix and its
|
|
idempotency on both fresh and legacy schemas.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from sqlalchemy import text
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
|
|
from backend.app.core.database import run_migrations
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def force_sqlite_dialect(monkeypatch):
|
|
"""Force the SQLite branch in run_migrations regardless of test env settings."""
|
|
from backend.app.core import db_dialect
|
|
|
|
monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
|
|
monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
|
|
from backend.app.core import database as database_module
|
|
|
|
monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
|
|
|
|
|
|
def _register_all_models():
|
|
"""Import every model so Base.metadata knows about them. run_migrations touches
|
|
multiple tables, so the full schema has to exist before calling it — mirrors the
|
|
pattern in test_ldap_migration.py."""
|
|
from backend.app.models import ( # noqa: F401
|
|
ams_history,
|
|
ams_label,
|
|
api_key,
|
|
archive,
|
|
color_catalog,
|
|
external_link,
|
|
filament,
|
|
group,
|
|
kprofile_note,
|
|
maintenance,
|
|
notification,
|
|
notification_template,
|
|
print_queue,
|
|
printer,
|
|
project,
|
|
project_bom,
|
|
settings,
|
|
slot_preset,
|
|
smart_plug,
|
|
smart_plug_energy_snapshot,
|
|
spool,
|
|
spool_assignment,
|
|
spool_catalog,
|
|
spool_k_profile,
|
|
spool_usage_history,
|
|
spoolbuddy_device,
|
|
user,
|
|
user_email_pref,
|
|
virtual_printer,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def legacy_engine():
|
|
"""Simulate a pre-UNIQUE install: full schema via create_all, then drop the
|
|
settings table and re-create it in the legacy shape (no UNIQUE on key).
|
|
This matches real-world upgrades where everything else is modern and only
|
|
the settings table carries the stale schema."""
|
|
from backend.app.core.database import Base
|
|
|
|
_register_all_models()
|
|
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
await conn.execute(text("DROP TABLE settings"))
|
|
await conn.execute(
|
|
text("""
|
|
CREATE TABLE settings (
|
|
id INTEGER PRIMARY KEY,
|
|
key TEXT,
|
|
value TEXT,
|
|
created_at TEXT,
|
|
updated_at TEXT
|
|
)
|
|
""")
|
|
)
|
|
yield engine
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.fixture
|
|
async def fresh_engine():
|
|
"""Simulate a fresh install: every table created from SQLAlchemy models, which
|
|
DOES emit the unique index on settings.key. Verifies the migration is a no-op."""
|
|
from backend.app.core.database import Base
|
|
|
|
_register_all_models()
|
|
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
yield engine
|
|
await engine.dispose()
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Legacy schema tests
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
async def test_legacy_schema_allows_duplicate_keys_before_migration(legacy_engine):
|
|
"""Sanity check: the legacy schema really does permit duplicates — protects
|
|
the migration test below from becoming a false-positive if the fixture drifts."""
|
|
async with legacy_engine.begin() as conn:
|
|
await conn.execute(text("INSERT INTO settings (key, value) VALUES ('advanced_auth_enabled', 'false')"))
|
|
await conn.execute(text("INSERT INTO settings (key, value) VALUES ('advanced_auth_enabled', 'false')"))
|
|
result = await conn.execute(text("SELECT COUNT(*) FROM settings WHERE key = 'advanced_auth_enabled'"))
|
|
assert result.scalar_one() == 2
|
|
|
|
|
|
async def test_migration_dedupes_and_adds_unique_index(legacy_engine):
|
|
"""Given a legacy DB with duplicate rows for the same key, run_migrations
|
|
should (a) delete duplicates keeping the lowest id, (b) add the unique index,
|
|
(c) make future duplicate inserts fail with IntegrityError."""
|
|
# Seed: two duplicate rows for the same key, with distinguishable values.
|
|
async with legacy_engine.begin() as conn:
|
|
await conn.execute(text("INSERT INTO settings (id, key, value) VALUES (1, 'advanced_auth_enabled', 'old')"))
|
|
await conn.execute(text("INSERT INTO settings (id, key, value) VALUES (2, 'advanced_auth_enabled', 'new')"))
|
|
# Also seed an unrelated key that should survive untouched.
|
|
await conn.execute(text("INSERT INTO settings (id, key, value) VALUES (3, 'other_key', 'keep_me')"))
|
|
|
|
async with legacy_engine.begin() as conn:
|
|
await run_migrations(conn)
|
|
|
|
async with legacy_engine.begin() as conn:
|
|
# Only the MIN(id) row for the duplicated key remains.
|
|
rows = (await conn.execute(text("SELECT id, value FROM settings WHERE key = 'advanced_auth_enabled'"))).all()
|
|
assert len(rows) == 1
|
|
assert rows[0].id == 1
|
|
assert rows[0].value == "old"
|
|
|
|
# Untouched key still present.
|
|
other = (await conn.execute(text("SELECT value FROM settings WHERE key = 'other_key'"))).scalar_one()
|
|
assert other == "keep_me"
|
|
|
|
# Unique constraint is now enforced — inserting a duplicate fails.
|
|
with pytest.raises(IntegrityError):
|
|
await conn.execute(text("INSERT INTO settings (key, value) VALUES ('advanced_auth_enabled', 'x')"))
|
|
|
|
|
|
async def test_migration_is_idempotent_on_already_clean_legacy(legacy_engine):
|
|
"""Running the migration twice must not crash — the second run finds no
|
|
duplicates and the CREATE UNIQUE INDEX IF NOT EXISTS is a no-op."""
|
|
async with legacy_engine.begin() as conn:
|
|
await conn.execute(text("INSERT INTO settings (key, value) VALUES ('k', 'v')"))
|
|
|
|
async with legacy_engine.begin() as conn:
|
|
await run_migrations(conn)
|
|
async with legacy_engine.begin() as conn:
|
|
await run_migrations(conn)
|
|
|
|
async with legacy_engine.begin() as conn:
|
|
count = (await conn.execute(text("SELECT COUNT(*) FROM settings WHERE key = 'k'"))).scalar_one()
|
|
assert count == 1
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Fresh-install test — migration must be a safe no-op
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
async def test_migration_is_noop_on_fresh_install(fresh_engine):
|
|
"""Fresh installs get the unique index from `create_all`. Running the
|
|
migration must not crash and must not alter the schema."""
|
|
async with fresh_engine.begin() as conn:
|
|
await run_migrations(conn)
|
|
|
|
async with fresh_engine.begin() as conn:
|
|
# Unique constraint still present — duplicate insert fails.
|
|
await conn.execute(text("INSERT INTO settings (key, value) VALUES ('k', 'v1')"))
|
|
with pytest.raises(IntegrityError):
|
|
await conn.execute(text("INSERT INTO settings (key, value) VALUES ('k', 'v2')"))
|