test_db_session_timezone.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. """Database-side timestamps are UTC on both dialects (#2855).
  2. Bambuddy stores naive datetimes that hold UTC, and the frontend's
  3. ``parseUTCDate()`` reads a timestamp with no offset as UTC. #504 swept the
  4. Python side onto ``datetime.now(timezone.utc)``, but roughly 96 columns take
  5. their value from ``server_default=func.now()`` and the migration DDL carries
  6. another ~49 ``DEFAULT CURRENT_TIMESTAMP`` — those are filled by the database.
  7. SQLite's ``CURRENT_TIMESTAMP`` is UTC by definition, which is why the gap stayed
  8. invisible for two years. PostgreSQL's ``now()`` is a ``timestamptz``, so writing
  9. it into a ``timestamp without time zone`` column casts it through the session
  10. ``TimeZone``, and a Postgres container started with ``TZ=Europe/Istanbul`` bakes
  11. that zone into postgresql.conf at initdb. Every defaulted timestamp then lands
  12. as local wall-clock and renders three hours in the future.
  13. Measured against a live PostgreSQL 16 while fixing this:
  14. no connect_args TimeZone=UTC now()::timestamp=05:50:46
  15. server_settings=Istanbul TimeZone=Europe/Istanbul now()::timestamp=08:50:46
  16. server_settings=UTC TimeZone=UTC now()::timestamp=05:50:46
  17. """
  18. import os
  19. import time
  20. from datetime import datetime, timedelta
  21. import pytest
  22. from sqlalchemy import Column, DateTime, Integer, MetaData, Table, func, select
  23. from sqlalchemy.ext.asyncio import create_async_engine
  24. class TestConnectArgs:
  25. """What we hand the driver, per dialect."""
  26. def test_sqlite_gets_none(self, monkeypatch):
  27. """SQLite has no session timezone to pin, and passing an unknown connect
  28. arg to aiosqlite would be a TypeError at connect time."""
  29. from backend.app.core import database
  30. monkeypatch.setattr(database, "is_sqlite", lambda: True)
  31. assert database._resolve_connect_args() == {}
  32. def test_asyncpg_pins_the_session_to_utc(self, monkeypatch):
  33. from backend.app.core import database
  34. monkeypatch.setattr(database, "is_sqlite", lambda: False)
  35. monkeypatch.setattr(
  36. database.settings, "database_url", "postgresql+asyncpg://u:p@host:5432/bambuddy", raising=False
  37. )
  38. assert database._resolve_connect_args() == {"server_settings": {"timezone": "UTC"}}
  39. def test_other_postgres_drivers_go_through_libpq(self, monkeypatch):
  40. """``server_settings`` is an asyncpg keyword. psycopg would reject it, so
  41. a non-asyncpg URL gets the same setting the libpq way."""
  42. from backend.app.core import database
  43. monkeypatch.setattr(database, "is_sqlite", lambda: False)
  44. monkeypatch.setattr(
  45. database.settings, "database_url", "postgresql+psycopg://u:p@host:5432/bambuddy", raising=False
  46. )
  47. assert database._resolve_connect_args() == {"options": "-c timezone=UTC"}
  48. def test_create_engine_actually_passes_them(self, monkeypatch):
  49. """The resolver is only useful if it reaches ``create_async_engine`` —
  50. pin the wiring, not just the value."""
  51. from backend.app.core import database
  52. captured: dict = {}
  53. def fake_create_async_engine(url, **kwargs):
  54. captured.update(kwargs)
  55. return create_async_engine("sqlite+aiosqlite:///:memory:")
  56. monkeypatch.setattr(database, "is_sqlite", lambda: False)
  57. monkeypatch.setattr(
  58. database.settings, "database_url", "postgresql+asyncpg://u:p@host:5432/bambuddy", raising=False
  59. )
  60. monkeypatch.setattr(database, "create_async_engine", fake_create_async_engine)
  61. database._create_engine()
  62. assert captured["connect_args"] == {"server_settings": {"timezone": "UTC"}}
  63. def test_sqlite_engine_gets_no_connect_args(self, monkeypatch):
  64. """aiosqlite would raise on an unexpected keyword, so the empty dict has
  65. to be dropped rather than passed through."""
  66. from backend.app.core import database
  67. captured: dict = {}
  68. def fake_create_async_engine(url, **kwargs):
  69. captured.update(kwargs)
  70. return create_async_engine("sqlite+aiosqlite:///:memory:")
  71. monkeypatch.setattr(database, "is_sqlite", lambda: True)
  72. monkeypatch.setattr(database, "create_async_engine", fake_create_async_engine)
  73. database._create_engine()
  74. assert "connect_args" not in captured
  75. @pytest.fixture
  76. def istanbul_tz():
  77. """Run the process on UTC+3, the reporter's zone."""
  78. original = os.environ.get("TZ")
  79. os.environ["TZ"] = "Europe/Istanbul"
  80. time.tzset()
  81. yield
  82. if original is None:
  83. del os.environ["TZ"]
  84. else:
  85. os.environ["TZ"] = original
  86. time.tzset()
  87. class TestSqliteIsTheReference:
  88. """SQLite is what Postgres is being made to match, so pin its behaviour."""
  89. @pytest.mark.asyncio
  90. async def test_server_default_writes_utc_not_local(self, istanbul_tz):
  91. """``server_default=func.now()`` compiles to ``CURRENT_TIMESTAMP``, which
  92. SQLite defines as UTC regardless of the host clock. If this ever changed,
  93. every naive timestamp in the product would shift by the host offset."""
  94. metadata = MetaData()
  95. probe = Table(
  96. "tz_probe",
  97. metadata,
  98. Column("id", Integer, primary_key=True),
  99. Column("created_at", DateTime, server_default=func.now()),
  100. )
  101. engine = create_async_engine("sqlite+aiosqlite:///:memory:")
  102. try:
  103. async with engine.begin() as conn:
  104. await conn.run_sync(metadata.create_all)
  105. await conn.execute(probe.insert())
  106. stored = (await conn.execute(select(probe.c.created_at))).scalar()
  107. finally:
  108. await engine.dispose()
  109. # Local is three hours ahead of UTC here; a stored local value would sit
  110. # ~3h from utcnow and only ~0s from the local clock.
  111. assert abs(stored - datetime.utcnow()) < timedelta(minutes=5)
  112. assert abs(stored - datetime.now()) > timedelta(hours=2)
  113. class TestQueueAgeUsesUtc:
  114. """The support bundle's ``oldest_pending_age_seconds`` (#2855)."""
  115. @pytest.mark.asyncio
  116. async def test_age_of_a_fresh_item_is_near_zero_on_a_non_utc_host(self, db_session, istanbul_tz):
  117. """The old code subtracted a naive *local* now() from a naive UTC column,
  118. so on UTC+3 a just-queued item reported as three hours old — and west of
  119. Greenwich the age came out negative."""
  120. from backend.app.api.routes.support import _collect_queue_info
  121. from backend.app.models.print_queue import PrintQueueItem
  122. from backend.app.utils.local_time import utcnow_naive
  123. db_session.add(PrintQueueItem(printer_id=1, status="pending", created_at=utcnow_naive()))
  124. await db_session.commit()
  125. info = await _collect_queue_info(db_session)
  126. assert info["pending_total"] == 1
  127. assert 0 <= info["oldest_pending_age_seconds"] < 60