test_supplier_tables_migration.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. """Migration tests for the supplier tables (#2988).
  2. A database that predates the feature must gain both tables on upgrade, and
  3. re-running the migration must be a no-op (CREATE TABLE IF NOT EXISTS via
  4. _safe_execute).
  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_without_supplier_tables():
  33. """create_all builds the current schema; dropping the tables reproduces a
  34. database from a Bambuddy version that predates #2988."""
  35. from backend.app.core.database import Base
  36. _register_all_models()
  37. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  38. async with engine.begin() as conn:
  39. await conn.run_sync(Base.metadata.create_all)
  40. await conn.execute(text("DROP TABLE spoolman_spool_suppliers"))
  41. await conn.execute(text("DROP TABLE spool_suppliers"))
  42. await conn.execute(text("DROP TABLE suppliers"))
  43. yield engine
  44. await engine.dispose()
  45. @pytest.fixture
  46. async def engine_with_pre_fix_suppliers():
  47. """A database written by an earlier build of this branch (#2988).
  48. The supplier tables are there, ``suppliers`` has no ``name_key`` column,
  49. and nothing stopped two rows whose names differ only in case -- which is
  50. exactly the state a unique index cannot be built over.
  51. """
  52. from backend.app.core.database import Base
  53. _register_all_models()
  54. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  55. async with engine.begin() as conn:
  56. await conn.run_sync(Base.metadata.create_all)
  57. await conn.execute(text("DROP TABLE suppliers"))
  58. await conn.execute(
  59. text(
  60. "CREATE TABLE suppliers ("
  61. " id INTEGER PRIMARY KEY AUTOINCREMENT,"
  62. " name VARCHAR(200) NOT NULL,"
  63. " website VARCHAR(500),"
  64. " customer_number VARCHAR(100),"
  65. " note VARCHAR(500),"
  66. " created_at DATETIME DEFAULT CURRENT_TIMESTAMP,"
  67. " updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)"
  68. )
  69. )
  70. yield engine
  71. await engine.dispose()
  72. async def test_migration_creates_supplier_tables(engine_without_supplier_tables):
  73. async with engine_without_supplier_tables.begin() as conn:
  74. await run_migrations(conn)
  75. async with engine_without_supplier_tables.begin() as conn:
  76. await conn.execute(text("INSERT INTO suppliers (name, name_key) VALUES ('Supplier A', 'supplier a')"))
  77. await conn.execute(
  78. text(
  79. """
  80. INSERT INTO spool (material, label_weight, core_weight, weight_used, weight_used_baseline, weight_locked)
  81. VALUES ('PLA', 1000, 250, 0, 0, 0)
  82. """
  83. )
  84. )
  85. await conn.execute(
  86. text(
  87. """
  88. INSERT INTO spool_suppliers (spool_id, supplier_id, quoted_price_per_kg, is_purchase_source)
  89. SELECT s.id, sup.id, 19.99, 1 FROM spool s, suppliers sup
  90. """
  91. )
  92. )
  93. # Spoolman twin (#2988 parity): local row keyed by the remote spool id.
  94. await conn.execute(
  95. text(
  96. """
  97. INSERT INTO spoolman_spool_suppliers (spoolman_spool_id, supplier_id, is_purchase_source)
  98. SELECT 7, sup.id, 1 FROM suppliers sup
  99. """
  100. )
  101. )
  102. async with engine_without_supplier_tables.connect() as conn:
  103. links = (await conn.execute(text("SELECT supplier_id, is_purchase_source FROM spool_suppliers"))).all()
  104. twin_links = (
  105. await conn.execute(text("SELECT spoolman_spool_id, supplier_id FROM spoolman_spool_suppliers"))
  106. ).all()
  107. assert len(links) == 1
  108. assert len(twin_links) == 1
  109. async def test_migration_is_idempotent(engine_without_supplier_tables):
  110. async with engine_without_supplier_tables.begin() as conn:
  111. await run_migrations(conn)
  112. async with engine_without_supplier_tables.begin() as conn:
  113. await conn.execute(text("INSERT INTO suppliers (name, name_key) VALUES ('Kept', 'kept')"))
  114. async with engine_without_supplier_tables.begin() as conn:
  115. await run_migrations(conn)
  116. async with engine_without_supplier_tables.connect() as conn:
  117. names = (await conn.execute(text("SELECT name FROM suppliers"))).scalars().all()
  118. # Existing rows survive the re-run — the CREATE is swallowed, not applied.
  119. assert names == ["Kept"]
  120. async def test_migration_enforces_case_insensitive_unique_names(engine_without_supplier_tables):
  121. """The name is what the CSV import resolves against (#2988), so an
  122. upgraded database gets the same unique index create_all gives a fresh one.
  123. Every spelling goes in with the key the application computes, which is the
  124. point of storing it: the fold is the Python one, so the umlauted variant
  125. is refused too. A unique index on ``lower(name)`` let that one through,
  126. because SQLite's ``lower()`` folds ASCII only.
  127. """
  128. from sqlalchemy.exc import IntegrityError
  129. from backend.app.models.supplier import supplier_name_key
  130. async def _insert(conn, name: str) -> None:
  131. await conn.execute(
  132. text("INSERT INTO suppliers (name, name_key) VALUES (:n, :k)"),
  133. {"n": name, "k": supplier_name_key(name)},
  134. )
  135. async with engine_without_supplier_tables.begin() as conn:
  136. await run_migrations(conn)
  137. async with engine_without_supplier_tables.begin() as conn:
  138. await _insert(conn, "Extrudr")
  139. await _insert(conn, "Ökofilament")
  140. for variant in ("extrudr", " eXtRuDr ", "ökofilament"):
  141. with pytest.raises(IntegrityError):
  142. async with engine_without_supplier_tables.begin() as conn:
  143. await _insert(conn, variant)
  144. async def test_upgraded_database_refuses_a_supplier_without_a_name_key(engine_without_supplier_tables):
  145. """The upgrade path declares name_key NOT NULL, as create_all() does on a
  146. fresh install (#2988). NULLs never collide in a unique index, so a row
  147. without a key would slip past the case-insensitive uniqueness."""
  148. from sqlalchemy.exc import IntegrityError
  149. async with engine_without_supplier_tables.begin() as conn:
  150. await run_migrations(conn)
  151. async with engine_without_supplier_tables.connect() as conn:
  152. columns = (await conn.execute(text("PRAGMA table_info(suppliers)"))).all()
  153. assert {c.name: c.notnull for c in columns}["name_key"] == 1
  154. with pytest.raises(IntegrityError):
  155. async with engine_without_supplier_tables.begin() as conn:
  156. await conn.execute(text("INSERT INTO suppliers (name) VALUES ('No Key')"))
  157. async def test_migration_collapses_duplicates_instead_of_aborting(engine_with_pre_fix_suppliers):
  158. """An upgrade over rows this branch itself allowed must not abort startup.
  159. CREATE UNIQUE INDEX refuses to build over the duplicates and _safe_execute
  160. re-raises that IntegrityError out of run_migrations, so without the
  161. collapse Bambuddy never finishes starting — and every migration queued
  162. after this one is skipped with it (#2988).
  163. Collapsed by merging, not deleting: a supplier is referenced. The oldest
  164. row wins — it is the one assignments and the import already resolved to —
  165. keeps its own spelling, takes over the assignments and fills its empty
  166. fields from the duplicate.
  167. """
  168. async with engine_with_pre_fix_suppliers.begin() as conn:
  169. await conn.execute(text("INSERT INTO suppliers (id, name) VALUES (1, 'Extrudr')"))
  170. await conn.execute(
  171. text("INSERT INTO suppliers (id, name, website) VALUES (2, 'extrudr', 'https://extrudr.example')")
  172. )
  173. await conn.execute(
  174. text(
  175. "INSERT INTO spool_suppliers (spool_id, supplier_id, supplier_article_number, is_purchase_source)"
  176. " VALUES (5, 2, 'EX-42', 1)"
  177. )
  178. )
  179. await conn.execute(
  180. text(
  181. "INSERT INTO spoolman_spool_suppliers (spoolman_spool_id, supplier_id, is_purchase_source) VALUES (7, 2, 0)"
  182. )
  183. )
  184. async with engine_with_pre_fix_suppliers.begin() as conn:
  185. await run_migrations(conn)
  186. async with engine_with_pre_fix_suppliers.connect() as conn:
  187. suppliers = (await conn.execute(text("SELECT id, name, name_key, website FROM suppliers"))).all()
  188. links = (
  189. await conn.execute(text("SELECT spool_id, supplier_id, supplier_article_number FROM spool_suppliers"))
  190. ).all()
  191. twins = (await conn.execute(text("SELECT spoolman_spool_id, supplier_id FROM spoolman_spool_suppliers"))).all()
  192. assert suppliers == [(1, "Extrudr", "extrudr", "https://extrudr.example")]
  193. assert links == [(5, 1, "EX-42")]
  194. assert twins == [(7, 1)]
  195. async def test_migration_collapses_non_ascii_case_variants(engine_with_pre_fix_suppliers):
  196. """These two are in the database precisely because the SQL fold is ASCII
  197. only: an index on lower(name) never saw them as the same name (#2988)."""
  198. async with engine_with_pre_fix_suppliers.begin() as conn:
  199. await conn.execute(text("INSERT INTO suppliers (id, name) VALUES (1, :n)"), {"n": "Ökofilament"})
  200. await conn.execute(text("INSERT INTO suppliers (id, name) VALUES (2, :n)"), {"n": "ökofilament"})
  201. async with engine_with_pre_fix_suppliers.begin() as conn:
  202. await run_migrations(conn)
  203. async with engine_with_pre_fix_suppliers.connect() as conn:
  204. rows = (await conn.execute(text("SELECT name, name_key FROM suppliers"))).all()
  205. assert rows == [("Ökofilament", "ökofilament")]
  206. async def test_merge_drops_an_assignment_the_surviving_row_already_has(engine_with_pre_fix_suppliers):
  207. """(spool, supplier) is unique, so a spool assigned to BOTH duplicates
  208. cannot have both rows re-pointed — the survivor's own row stays."""
  209. async with engine_with_pre_fix_suppliers.begin() as conn:
  210. await conn.execute(text("INSERT INTO suppliers (id, name) VALUES (1, 'Extrudr'), (2, 'EXTRUDR')"))
  211. await conn.execute(
  212. text(
  213. "INSERT INTO spool_suppliers (spool_id, supplier_id, supplier_article_number, is_purchase_source)"
  214. " VALUES (5, 1, 'KEPT', 0), (5, 2, 'DROPPED', 0), (6, 2, 'MOVED', 0)"
  215. )
  216. )
  217. async with engine_with_pre_fix_suppliers.begin() as conn:
  218. await run_migrations(conn)
  219. async with engine_with_pre_fix_suppliers.connect() as conn:
  220. links = (
  221. await conn.execute(
  222. text("SELECT spool_id, supplier_id, supplier_article_number FROM spool_suppliers ORDER BY spool_id")
  223. )
  224. ).all()
  225. assert links == [(5, 1, "KEPT"), (6, 1, "MOVED")]