mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
210 lines
8.1 KiB
Python
210 lines
8.1 KiB
Python
"""Regression tests for storage-location migration backfill (#1004).
|
|
|
|
Legacy installs may have free-text storage_location values that differ only
|
|
by case. The backfill must collapse them to one catalog row and stay
|
|
idempotent across restarts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from sqlalchemy import text
|
|
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):
|
|
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 backend.app.models # noqa: F401
|
|
from backend.app.models import ( # noqa: F401
|
|
external_link,
|
|
location,
|
|
print_log,
|
|
print_queue,
|
|
project_bom,
|
|
slot_preset,
|
|
spoolman_k_profile,
|
|
spoolman_slot_assignment,
|
|
virtual_printer,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def engine_with_case_variant_spools():
|
|
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("DELETE FROM locations"))
|
|
await conn.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO spool (
|
|
material, storage_location, label_weight, core_weight,
|
|
weight_used, weight_used_baseline, weight_locked
|
|
)
|
|
VALUES ('PLA', 'Drybox 1', 1000, 250, 0, 0, 0),
|
|
('PETG', 'DRYBOX 1', 1000, 250, 0, 0, 0)
|
|
"""
|
|
)
|
|
)
|
|
yield engine
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_backfill_collapses_case_variant_storage_locations(engine_with_case_variant_spools):
|
|
async with engine_with_case_variant_spools.begin() as conn:
|
|
await run_migrations(conn)
|
|
|
|
async with engine_with_case_variant_spools.connect() as conn:
|
|
loc_rows = (await conn.execute(text("SELECT id, name, name_key FROM locations ORDER BY id"))).all()
|
|
spool_rows = (await conn.execute(text("SELECT id, storage_location, location_id FROM spool ORDER BY id"))).all()
|
|
|
|
assert len(loc_rows) == 1
|
|
assert loc_rows[0].name_key == "drybox 1"
|
|
location_id = loc_rows[0].id
|
|
assert all(row.location_id == location_id for row in spool_rows)
|
|
|
|
|
|
async def test_backfill_is_idempotent_with_existing_locations(engine_with_case_variant_spools):
|
|
async with engine_with_case_variant_spools.begin() as conn:
|
|
await run_migrations(conn)
|
|
async with engine_with_case_variant_spools.begin() as conn:
|
|
await run_migrations(conn)
|
|
|
|
async with engine_with_case_variant_spools.connect() as conn:
|
|
loc_count = (await conn.execute(text("SELECT COUNT(*) FROM locations"))).scalar_one()
|
|
linked = (await conn.execute(text("SELECT COUNT(*) FROM spool WHERE location_id IS NOT NULL"))).scalar_one()
|
|
|
|
assert loc_count == 1
|
|
assert linked == 2
|
|
|
|
|
|
@pytest.fixture
|
|
async def engine_with_null_storage_location():
|
|
"""A spool with NULL storage_location must NOT produce a phantom location row
|
|
or get linked to anything — it stays NULL on both fields."""
|
|
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("DELETE FROM locations"))
|
|
await conn.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO spool (
|
|
material, storage_location, label_weight, core_weight,
|
|
weight_used, weight_used_baseline, weight_locked
|
|
)
|
|
VALUES ('PLA', NULL, 1000, 250, 0, 0, 0),
|
|
('PETG', ' ', 1000, 250, 0, 0, 0),
|
|
('TPU', 'Real Shelf', 1000, 250, 0, 0, 0)
|
|
"""
|
|
)
|
|
)
|
|
yield engine
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_backfill_skips_null_and_whitespace_storage_location(
|
|
engine_with_null_storage_location,
|
|
):
|
|
"""NULL / whitespace-only `storage_location` rows must NOT create catalog
|
|
rows; only the 'Real Shelf' value gets a location row + spool link."""
|
|
async with engine_with_null_storage_location.begin() as conn:
|
|
await run_migrations(conn)
|
|
|
|
async with engine_with_null_storage_location.connect() as conn:
|
|
loc_rows = (await conn.execute(text("SELECT name FROM locations"))).all()
|
|
unlinked = (
|
|
await conn.execute(text("SELECT material FROM spool WHERE location_id IS NULL ORDER BY material"))
|
|
).all()
|
|
|
|
# Only the row with a real storage_location should be in the catalog.
|
|
assert [r.name for r in loc_rows] == ["Real Shelf"]
|
|
# The NULL and whitespace-only spools stay unlinked (no phantom row).
|
|
assert [r.material for r in unlinked] == ["PETG", "PLA"]
|
|
|
|
|
|
@pytest.fixture
|
|
async def engine_with_legacy_null_name_key_location():
|
|
"""Simulate a legacy install where a `locations` row was manually inserted
|
|
BEFORE the name_key column existed. The migration must backfill the
|
|
legacy row's name_key BEFORE the dedup INSERT, so the spool-link UPDATE
|
|
can join on the new key (#1505 review IMPORTANT 11)."""
|
|
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)
|
|
# Drop the model-shaped locations table (which has NOT NULL on
|
|
# name_key) and recreate it in its pre-migration shape: no name_key
|
|
# column at all, mirroring a real upgrade from a Bambuddy version
|
|
# that predates this feature. The migration's idempotent ALTER TABLE
|
|
# is what adds the column without a NOT NULL constraint, so the
|
|
# legacy row can legally have NULL until the new backfill UPDATE
|
|
# runs.
|
|
await conn.execute(text("DROP TABLE locations"))
|
|
await conn.execute(
|
|
text(
|
|
"""
|
|
CREATE TABLE locations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name VARCHAR(255) NOT NULL UNIQUE,
|
|
identifier VARCHAR(100),
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
"""
|
|
)
|
|
)
|
|
await conn.execute(text("INSERT INTO locations (name) VALUES ('Drybox 1')"))
|
|
await conn.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO spool (
|
|
material, storage_location, label_weight, core_weight,
|
|
weight_used, weight_used_baseline, weight_locked
|
|
)
|
|
VALUES ('PLA', 'Drybox 1', 1000, 250, 0, 0, 0)
|
|
"""
|
|
)
|
|
)
|
|
yield engine
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_backfill_links_spool_to_legacy_null_name_key_location(
|
|
engine_with_legacy_null_name_key_location,
|
|
):
|
|
async with engine_with_legacy_null_name_key_location.begin() as conn:
|
|
await run_migrations(conn)
|
|
|
|
async with engine_with_legacy_null_name_key_location.connect() as conn:
|
|
loc_rows = (await conn.execute(text("SELECT id, name, name_key FROM locations"))).all()
|
|
spool_rows = (await conn.execute(text("SELECT location_id FROM spool"))).all()
|
|
|
|
# Exactly one location row (the pre-existing legacy one); its name_key
|
|
# got backfilled by the FIRST step of the migration.
|
|
assert len(loc_rows) == 1
|
|
assert loc_rows[0].name_key == "drybox 1"
|
|
# The spool got linked to that legacy row — under the old ordering it
|
|
# would have been left with `location_id IS NULL`.
|
|
assert spool_rows[0].location_id == loc_rows[0].id
|