test_sqlite_import_sequences.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. """A SQLite backup imported into PostgreSQL keeps ids freed on SQLite unused.
  2. The import set each sequence to the highest id left, so the next printer on
  3. PostgreSQL got the id of a deleted one, and with it whatever still pointed at
  4. that id (its print log history, a Notify tile, a token). The sequence now
  5. starts above SQLite's own counter and above the ids still referred to, and
  6. installs imported before this are repaired at start.
  7. """
  8. import re
  9. import sqlite3
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import pytest
  12. pytestmark = pytest.mark.asyncio
  13. def _source(path, *, deleted_highest: bool):
  14. conn = sqlite3.connect(str(path))
  15. conn.execute(
  16. "CREATE TABLE printers (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, serial_number TEXT, "
  17. "ip_address TEXT, access_code TEXT)"
  18. )
  19. for i in range(1, 6):
  20. conn.execute(
  21. "INSERT INTO printers (id, name, serial_number, ip_address, access_code) VALUES (?, ?, ?, '192.0.2.1', 'x')",
  22. (i, f"p{i}", f"S{i}"),
  23. )
  24. if deleted_highest:
  25. conn.execute("DELETE FROM printers WHERE id = 5")
  26. conn.commit()
  27. conn.close()
  28. async def _import(path, pg_values: dict[str, int]) -> list[str]:
  29. """Run the import on a fake PostgreSQL that answers max() queries from pg_values."""
  30. from backend.app.api.routes import settings as settings_module
  31. executed: list[str] = []
  32. async def execute(stmt, *_a, **_k):
  33. sql = getattr(stmt, "text", str(stmt))
  34. executed.append(sql)
  35. value = None
  36. for pattern, v in pg_values.items():
  37. if re.search(pattern, sql, re.IGNORECASE):
  38. value = v
  39. result = MagicMock()
  40. result.scalar = MagicMock(return_value=value)
  41. return result
  42. conn = MagicMock()
  43. conn.execute = AsyncMock(side_effect=execute)
  44. conn.run_sync = AsyncMock()
  45. begin = MagicMock()
  46. begin.__aenter__ = AsyncMock(return_value=conn)
  47. begin.__aexit__ = AsyncMock(return_value=False)
  48. engine = MagicMock()
  49. engine.begin = MagicMock(return_value=begin)
  50. engine.dispose = AsyncMock()
  51. with patch("backend.app.core.database._create_engine", new=MagicMock(return_value=engine)):
  52. await settings_module._import_sqlite_to_postgres(path)
  53. return executed
  54. def _setval(executed: list[str], table: str) -> int | None:
  55. for sql in executed:
  56. m = re.search(rf"setval\('{table}_id_seq', (\d+)\)", sql)
  57. if m:
  58. return int(m.group(1))
  59. return None
  60. async def test_an_id_freed_on_sqlite_is_not_given_out_on_postgres(tmp_path):
  61. path = tmp_path / "b.db"
  62. _source(path, deleted_highest=True)
  63. executed = await _import(path, {r"MAX\(id\) FROM printers": 4})
  64. assert _setval(executed, "printers") == 5
  65. async def test_an_id_still_referred_to_is_not_given_out_on_postgres(tmp_path):
  66. """A backup from before the counter was kept: the print log still says 7."""
  67. path = tmp_path / "b.db"
  68. _source(path, deleted_highest=False)
  69. executed = await _import(path, {r"MAX\(id\) FROM printers": 5, r"max\(printer_id\) FROM print_log_entries": 7})
  70. assert _setval(executed, "printers") == 7
  71. async def _start(pg: dict[str, tuple[int, bool]], referenced: dict[str, int]) -> list[tuple[str, int]]:
  72. """Run the start-up step on a fake PostgreSQL: pg maps a table to its
  73. sequence's (last_value, is_called), referenced to its highest id in use."""
  74. from backend.app.core import database
  75. raised: list[tuple[str, int]] = []
  76. async def execute(stmt, params=None, *_a, **_k):
  77. sql = getattr(stmt, "text", str(stmt))
  78. result = MagicMock()
  79. if "pg_get_serial_sequence" in sql:
  80. result.scalar = MagicMock(return_value=f"public.{params['t']}_id_seq")
  81. elif "last_value" in sql:
  82. table = re.search(r"public\.(\w+)_id_seq", sql).group(1)
  83. result.first = MagicMock(return_value=pg.get(table, (1, False)))
  84. elif "setval" in sql:
  85. raised.append((params["s"], params["v"]))
  86. return result
  87. conn = MagicMock()
  88. conn.execute = AsyncMock(side_effect=execute)
  89. with (
  90. patch.object(database, "is_sqlite", return_value=False),
  91. patch.object(database, "highest_referenced_id", AsyncMock(side_effect=lambda _c, t: referenced.get(t, 0))),
  92. ):
  93. await database._raise_postgres_id_sequences(conn)
  94. return raised
  95. async def test_an_install_imported_earlier_is_repaired_at_start():
  96. """Imported before the import kept them: the sequence stands at 4, the
  97. print log still says 5."""
  98. raised = await _start({"printers": (4, True)}, {"printers": 5})
  99. assert raised == [("public.printers_id_seq", 5)]
  100. async def test_a_sequence_is_never_lowered():
  101. assert await _start({"printers": (9, True), "users": (3, False)}, {"printers": 5, "users": 2}) == []
  102. async def test_a_sequence_not_yet_used_counts_from_its_start():
  103. """last_value 1, never called: id 1 is still to come, so 1 in use must raise it."""
  104. assert await _start({"users": (1, False)}, {"users": 1}) == [("public.users_id_seq", 1)]