test_failure_reason_vocabulary_migration.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. """Migration tests for issue #2974 — one vocabulary for ``failure_reason``.
  2. Three writers used to put three spellings of one cause into the column: the
  3. backend wrote English display labels ("Layer shift"), older builds of the
  4. archive editor wrote the *translated* label in whatever locale that user was
  5. running, and two stale-archive paths wrote English prose sentences. The Failure
  6. Analysis widget groups on the raw column, so one real cause occupied several
  7. buckets.
  8. Measured on a live install before this landed: ``print_log_entries`` held 91
  9. rows reading ``"User cancelled"`` beside 1 reading ``"userCancelled"``. In an
  10. English UI those render as the same words twice with different counts, which is
  11. why nobody spotted it; in any other locale one of the two stays English.
  12. """
  13. import pytest
  14. from sqlalchemy import select
  15. from sqlalchemy.ext.asyncio import AsyncSession
  16. from backend.app.core.database import (
  17. _LEGACY_FAILURE_REASON_LABELS,
  18. _migrate_failure_reason_vocabulary,
  19. )
  20. from backend.app.models.archive import PrintArchive
  21. from backend.app.models.print_log import PrintLogEntry
  22. # The columns each model needs beyond ``failure_reason``. Rows stay minimal on
  23. # purpose -- these tests exercise the UPDATE, not the schema.
  24. _REQUIRED = {
  25. # nosec B108 - a column value, not a path anything opens. The row exists
  26. # to be UPDATEd; nothing in these tests touches the filesystem.
  27. "PrintArchive": {"filename": "x.3mf", "file_path": "/tmp/x.3mf", "file_size": 1}, # nosec B108
  28. "PrintLogEntry": {},
  29. }
  30. async def _seed(session: AsyncSession, model, values: list[str | None]) -> list[int]:
  31. """Insert one row per value through the ORM and return their ids, in order."""
  32. required = _REQUIRED[model.__name__]
  33. rows = [model(status="failed", failure_reason=v, **required) for v in values]
  34. session.add_all(rows)
  35. await session.commit()
  36. ids = []
  37. for row in rows:
  38. await session.refresh(row)
  39. ids.append(row.id)
  40. return ids
  41. async def _read(session: AsyncSession, model, ids: list[int]) -> list[str | None]:
  42. """Read ``failure_reason`` back for ``ids``, in the order given."""
  43. session.expire_all()
  44. result = await session.execute(select(model.id, model.failure_reason).where(model.id.in_(ids)))
  45. got = {row[0]: row[1] for row in result.fetchall()}
  46. return [got[i] for i in ids]
  47. async def _run(session: AsyncSession) -> None:
  48. """Drive the migration over the session's own connection.
  49. TEST_DATABASE_URL is in-memory SQLite on a shared pool, so opening a second
  50. connection would not see the seeded rows -- and production calls this with
  51. an ``AsyncConnection`` inside an open transaction anyway, which is exactly
  52. what ``session.connection()`` hands over.
  53. """
  54. await _migrate_failure_reason_vocabulary(await session.connection())
  55. await session.commit()
  56. # ---------------------------------------------------------------------------
  57. # The map itself
  58. # ---------------------------------------------------------------------------
  59. def test_every_mapped_value_is_a_canonical_key() -> None:
  60. """A label may only ever fold onto a key the rest of the stack accepts."""
  61. from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
  62. offenders = sorted(set(_LEGACY_FAILURE_REASON_LABELS.values()) - _FAILURE_REASON_KEYS)
  63. assert not offenders, f"map targets values nothing else recognises: {offenders}"
  64. def test_the_map_is_unambiguous() -> None:
  65. """No label may resolve to two different keys.
  66. This is what makes the conversion exact rather than a guess, and it is the
  67. property that let the migration be written at all -- the reporter's open
  68. question was what to do with a value matching no key.
  69. """
  70. assert len(_LEGACY_FAILURE_REASON_LABELS) == len(set(_LEGACY_FAILURE_REASON_LABELS))
  71. def test_the_map_covers_every_writer_that_ever_existed() -> None:
  72. """The three historical vocabularies, by example."""
  73. m = _LEGACY_FAILURE_REASON_LABELS
  74. # 1. Backend English display labels.
  75. assert m["Layer shift"] == "layerShift"
  76. assert m["Filament runout"] == "filamentRunout"
  77. assert m["Clogged nozzle"] == "cloggedNozzle"
  78. assert m["User cancelled"] == "userCancelled"
  79. # 2. Legacy archive-editor writes of a *translated* label. Not English --
  80. # that is the whole reason a locale-dependent reverse lookup could not
  81. # fix this on read.
  82. assert m["Schichtversatz"] == "layerShift"
  83. assert m["Сдвиг слоёв"] == "layerShift"
  84. # 3. The two stale-path prose sentences.
  85. assert m["Stale - print likely cancelled or failed without status update"] == "noStatusUpdate"
  86. assert m["Stale - reconciled after reconnect, end time unknown"] == "noStatusUpdate"
  87. # ---------------------------------------------------------------------------
  88. # The migration
  89. # ---------------------------------------------------------------------------
  90. @pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
  91. async def test_labels_fold_onto_keys(db_session: AsyncSession, model) -> None:
  92. ids = await _seed(db_session, model, ["Layer shift", "Schichtversatz", "layerShift"])
  93. await _run(db_session)
  94. assert await _read(db_session, model, ids) == ["layerShift", "layerShift", "layerShift"]
  95. @pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
  96. async def test_the_live_split_collapses(db_session: AsyncSession, model) -> None:
  97. """The exact shape measured on the maintainer's instance."""
  98. ids = await _seed(db_session, model, ["User cancelled"] * 3 + ["userCancelled"])
  99. await _run(db_session)
  100. assert set(await _read(db_session, model, ids)) == {"userCancelled"}
  101. @pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
  102. async def test_both_stale_sentences_become_one_key(db_session: AsyncSession, model) -> None:
  103. ids = await _seed(
  104. db_session,
  105. model,
  106. [
  107. "Stale - print likely cancelled or failed without status update",
  108. "Stale - reconciled after reconnect, end time unknown",
  109. ],
  110. )
  111. await _run(db_session)
  112. assert await _read(db_session, model, ids) == ["noStatusUpdate", "noStatusUpdate"]
  113. @pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
  114. async def test_unrecognised_values_are_left_alone(db_session: AsyncSession, model) -> None:
  115. """Free text and NULL survive untouched.
  116. Guessing at a value the map does not know would be worse than leaving one
  117. honest string in its own bucket -- it still renders through the
  118. ``defaultValue`` fallback in the editor and the Statistics breakdown.
  119. """
  120. ids = await _seed(db_session, model, ["Custom legacy reason", None, ""])
  121. await _run(db_session)
  122. assert await _read(db_session, model, ids) == ["Custom legacy reason", None, ""]
  123. @pytest.mark.parametrize("model", [PrintArchive, PrintLogEntry])
  124. async def test_running_twice_changes_nothing(db_session: AsyncSession, model) -> None:
  125. """Self-terminating, which is why it carries no one-shot settings flag.
  126. A user restoring an older database, or upgrading through this version
  127. twice, must still get their legacy rows converted -- a flag would skip them
  128. forever.
  129. """
  130. ids = await _seed(db_session, model, ["Layer shift", "Custom legacy reason"])
  131. await _run(db_session)
  132. first = await _read(db_session, model, ids)
  133. await _run(db_session)
  134. assert await _read(db_session, model, ids) == first == ["layerShift", "Custom legacy reason"]
  135. # ---------------------------------------------------------------------------
  136. # Position within run_migrations
  137. # ---------------------------------------------------------------------------
  138. async def test_upgrade_from_before_print_log_failure_reason_boots(tmp_path) -> None:
  139. """A database older than #1378 has no print_log_entries.failure_reason.
  140. The conversion used to run at the top of run_migrations, before the ALTER
  141. that adds that column, so upgrading such a database crashed startup with
  142. "no such column: failure_reason". Run the real migration sequence over a
  143. database shaped like that and require it to finish, add the column, and
  144. still convert the labels that were already there.
  145. """
  146. from sqlalchemy import text
  147. from sqlalchemy.ext.asyncio import create_async_engine
  148. from backend.app.core.database import Base, run_migrations
  149. # Register every model on Base.metadata, as init_db does before create_all;
  150. # otherwise tables the migrations ALTER would be missing.
  151. from backend.app.main import app # noqa: F401
  152. engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'old.db'}")
  153. try:
  154. async with engine.begin() as conn:
  155. await conn.run_sync(Base.metadata.create_all)
  156. await conn.execute(text("ALTER TABLE print_log_entries DROP COLUMN failure_reason"))
  157. async with AsyncSession(engine) as session:
  158. session.add(PrintArchive(status="failed", failure_reason="Layer shift", **_REQUIRED["PrintArchive"]))
  159. await session.commit()
  160. async with engine.begin() as conn:
  161. await run_migrations(conn)
  162. async with engine.connect() as conn:
  163. columns = {row[1] for row in (await conn.execute(text("PRAGMA table_info(print_log_entries)"))).all()}
  164. reason = (await conn.execute(text("SELECT failure_reason FROM print_archives"))).scalar_one()
  165. assert "failure_reason" in columns
  166. assert reason == "layerShift"
  167. finally:
  168. await engine.dispose()
  169. async def test_archive_missing_from_the_fts_index_is_converted(tmp_path) -> None:
  170. """Archives created before archive_fts existed were never indexed.
  171. Updating such a row fires the FTS 'delete' trigger for a row the index
  172. doesn't hold, which SQLite reports as "database disk image is malformed" --
  173. fatal at startup. The conversion must rebuild the index first.
  174. """
  175. from sqlalchemy import text
  176. from sqlalchemy.ext.asyncio import create_async_engine
  177. from backend.app.core.database import Base, run_migrations
  178. # Register every model on Base.metadata, as init_db does before create_all.
  179. from backend.app.main import app # noqa: F401
  180. engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'fts.db'}")
  181. try:
  182. async with engine.begin() as conn:
  183. await conn.run_sync(Base.metadata.create_all)
  184. await run_migrations(conn) # creates archive_fts and its triggers
  185. async with AsyncSession(engine) as session:
  186. session.add(PrintArchive(status="failed", failure_reason="Layer shift", **_REQUIRED["PrintArchive"]))
  187. await session.commit()
  188. async with engine.begin() as conn:
  189. # Empty the index: the archive is now what a pre-FTS row looks like.
  190. await conn.execute(text("INSERT INTO archive_fts(archive_fts) VALUES('delete-all')"))
  191. async with engine.begin() as conn:
  192. await _migrate_failure_reason_vocabulary(conn)
  193. async with engine.connect() as conn:
  194. reason = (await conn.execute(text("SELECT failure_reason FROM print_archives"))).scalar_one()
  195. assert reason == "layerShift"
  196. finally:
  197. await engine.dispose()