test_spoolman_slot_rack_ams_migration.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. """spoolman_slot_assignments admits the H3C's AMS 3H: ams_id 32 with slots 0-8, externals (255, 2-8).
  2. Slots 4-8 exist only on the AMS 3H and among an H3C's external inputs, so the
  3. tray CHECK is ``tray_id >= 0 AND (tray_id <= 3 OR (ams_id IN (32, 255) AND
  4. tray_id <= 8))``. An upgraded SQLite table is rebuilt to it from upstream's
  5. CHECKs and from the plain ``tray_id <= 8`` an earlier build of this support
  6. wrote; a row the latter let through on another unit is dropped.
  7. """
  8. from __future__ import annotations
  9. import pytest
  10. from sqlalchemy import text
  11. from sqlalchemy.exc import IntegrityError
  12. from sqlalchemy.ext.asyncio import create_async_engine
  13. from backend.app.core.database import _migrate_widen_spoolman_slot_ams_id_range
  14. from backend.app.models.spoolman_slot_assignment import AMS_ID_CHECK, TRAY_ID_CHECK
  15. _TABLE = """
  16. CREATE TABLE spoolman_slot_assignments (
  17. id INTEGER NOT NULL,
  18. printer_id INTEGER NOT NULL,
  19. ams_id INTEGER NOT NULL,
  20. tray_id INTEGER NOT NULL,
  21. spoolman_spool_id INTEGER NOT NULL,
  22. assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
  23. PRIMARY KEY (id),
  24. CONSTRAINT uq_slot_assignment UNIQUE (printer_id, ams_id, tray_id),
  25. CONSTRAINT ck_ams_id_range CHECK ({ams}),
  26. CONSTRAINT ck_tray_id_range CHECK ({tray})
  27. )
  28. """
  29. _AMS_1274 = "(ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255"
  30. # The table as create_all built it for the #1274 model (upstream dev).
  31. TABLE_1274 = _TABLE.format(ams=_AMS_1274, tray="tray_id >= 0 AND tray_id <= 3")
  32. # As an earlier build of the H3C support left it: by its own SQLite rebuild...
  33. TABLE_WIDENED_REBUILT = (
  34. "CREATE TABLE spoolman_slot_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, "
  35. "printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE, "
  36. f"ams_id INTEGER NOT NULL CHECK ({AMS_ID_CHECK}), "
  37. "tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 8), "
  38. "spoolman_spool_id INTEGER NOT NULL, assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
  39. "CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id))"
  40. )
  41. # ...and by the live-install script, which rewrote the create_all form in place.
  42. TABLE_WIDENED_IN_PLACE = _TABLE.format(ams=AMS_ID_CHECK, tray="tray_id >= 0 AND tray_id <= 8")
  43. TABLE_FINAL = _TABLE.format(ams=AMS_ID_CHECK, tray=TRAY_ID_CHECK)
  44. @pytest.fixture(autouse=True)
  45. def force_sqlite_dialect(monkeypatch):
  46. from backend.app.core import database as database_module, db_dialect
  47. monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
  48. monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
  49. monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
  50. async def _insert(conn, ams_id: int, tray_id: int, spool: int) -> None:
  51. await conn.execute(
  52. text(
  53. "INSERT INTO spoolman_slot_assignments (printer_id, ams_id, tray_id, spoolman_spool_id) "
  54. "VALUES (1, :a, :t, :s)"
  55. ),
  56. {"a": ams_id, "t": tray_id, "s": spool},
  57. )
  58. async def _rows(conn) -> list[tuple]:
  59. rows = await conn.execute(
  60. text("SELECT ams_id, tray_id, spoolman_spool_id FROM spoolman_slot_assignments ORDER BY id")
  61. )
  62. return [tuple(r) for r in rows.fetchall()]
  63. async def _assert_final_checks(conn) -> None:
  64. await _insert(conn, 32, 8, 14) # AMS 3H slot 9
  65. await _insert(conn, 255, 5, 15) # an H3C external input (250)
  66. for ams_id, tray_id in ((0, 4), (128, 5), (8, 0), (32, 9), (40, 0), (255, 9)):
  67. with pytest.raises(IntegrityError):
  68. async with conn.begin_nested():
  69. await _insert(conn, ams_id, tray_id, 99)
  70. indexes = (
  71. await conn.execute(
  72. text("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='spoolman_slot_assignments'")
  73. )
  74. ).fetchall()
  75. assert "ix_slot_assignment_spool" in {r[0] for r in indexes}
  76. async def _table(conn, ddl: str) -> None:
  77. await conn.execute(text(ddl))
  78. await conn.execute(text("CREATE INDEX ix_slot_assignment_spool ON spoolman_slot_assignments (spoolman_spool_id)"))
  79. @pytest.mark.asyncio
  80. async def test_upstreams_table_takes_the_ams3h_and_keeps_its_rows():
  81. engine = create_async_engine("sqlite+aiosqlite:///:memory:")
  82. async with engine.begin() as conn:
  83. await _table(conn, TABLE_1274)
  84. await _insert(conn, 0, 3, 11)
  85. await _insert(conn, 128, 0, 12)
  86. await _insert(conn, 255, 1, 13)
  87. with pytest.raises(IntegrityError):
  88. async with conn.begin_nested():
  89. await _insert(conn, 32, 8, 14)
  90. await _migrate_widen_spoolman_slot_ams_id_range(conn)
  91. await _migrate_widen_spoolman_slot_ams_id_range(conn) # a second start changes nothing
  92. assert await _rows(conn) == [(0, 3, 11), (128, 0, 12), (255, 1, 13)]
  93. await _assert_final_checks(conn)
  94. await engine.dispose()
  95. @pytest.mark.asyncio
  96. @pytest.mark.parametrize("ddl", [TABLE_WIDENED_REBUILT, TABLE_WIDENED_IN_PLACE])
  97. async def test_a_table_an_earlier_build_widened_is_narrowed_to_real_slots(ddl):
  98. engine = create_async_engine("sqlite+aiosqlite:///:memory:")
  99. async with engine.begin() as conn:
  100. await _table(conn, ddl)
  101. await _insert(conn, 32, 6, 11)
  102. await _insert(conn, 255, 4, 12)
  103. await _insert(conn, 0, 6, 13) # slot 7 of a regular AMS: no such slot
  104. await _migrate_widen_spoolman_slot_ams_id_range(conn)
  105. await _migrate_widen_spoolman_slot_ams_id_range(conn)
  106. assert await _rows(conn) == [(32, 6, 11), (255, 4, 12)]
  107. await _assert_final_checks(conn)
  108. await engine.dispose()
  109. @pytest.mark.asyncio
  110. async def test_a_final_table_is_left_alone():
  111. engine = create_async_engine("sqlite+aiosqlite:///:memory:")
  112. async with engine.begin() as conn:
  113. await _table(conn, TABLE_FINAL)
  114. await _insert(conn, 32, 6, 11)
  115. before = (
  116. await conn.execute(text("SELECT sql FROM sqlite_master WHERE name='spoolman_slot_assignments'"))
  117. ).scalar()
  118. await _migrate_widen_spoolman_slot_ams_id_range(conn)
  119. after = (
  120. await conn.execute(text("SELECT sql FROM sqlite_master WHERE name='spoolman_slot_assignments'"))
  121. ).scalar()
  122. assert after == before
  123. assert await _rows(conn) == [(32, 6, 11)]
  124. await _assert_final_checks(conn)
  125. await engine.dispose()
  126. def test_the_model_admits_what_the_migration_does():
  127. from sqlalchemy import CheckConstraint
  128. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  129. checks = {
  130. c.name: str(c.sqltext) for c in SpoolmanSlotAssignment.__table__.constraints if isinstance(c, CheckConstraint)
  131. }
  132. assert checks["ck_ams_id_range"] == AMS_ID_CHECK
  133. assert (
  134. checks["ck_tray_id_range"]
  135. == TRAY_ID_CHECK
  136. == ("tray_id >= 0 AND (tray_id <= 3 OR (ams_id IN (32, 255) AND tray_id <= 8))")
  137. )
  138. def test_the_fresh_install_ddl_matches_the_model():
  139. import inspect
  140. import backend.app.core.database as database_module
  141. source = inspect.getsource(database_module)
  142. for dialect_marker in ("id INTEGER PRIMARY KEY AUTOINCREMENT,\n printer_id", "id SERIAL PRIMARY KEY,"):
  143. start = source.index("CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (\n " + dialect_marker)
  144. ddl = source[start : source.index(')\n """', start)]
  145. assert f"CHECK ({AMS_ID_CHECK})" in ddl and f"CHECK ({TRAY_ID_CHECK})" in ddl
  146. def test_the_request_schema_admits_the_ams3h():
  147. from pydantic import TypeAdapter, ValidationError
  148. from backend.app.schemas.spoolman import SlotAmsId, SlotTrayId
  149. assert TypeAdapter(SlotAmsId).validate_python(32) == 32
  150. assert TypeAdapter(SlotTrayId).validate_python(8) == 8
  151. for bad_ams in (8, 31, 33):
  152. with pytest.raises(ValidationError):
  153. TypeAdapter(SlotAmsId).validate_python(bad_ams)
  154. with pytest.raises(ValidationError):
  155. TypeAdapter(SlotTrayId).validate_python(9)