test_plug_datetimes_are_naive_utc.py 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  1. """Smart-plug timestamps must be naive UTC (#2539 collateral).
  2. Every ``DateTime`` column in the smart-plug tables is naive, and Bambuddy's
  3. convention is that a naive column holds UTC. The smart-plug code wrote *aware*
  4. datetimes into them anyway. SQLite tolerates that — its bind processor reads the
  5. datetime's fields and drops the offset — so it went unnoticed for a long time.
  6. **asyncpg does not.** It raises ``DataError: invalid input for query argument``,
  7. which meant that on Postgres:
  8. * every energy snapshot capture raised, so the snapshot table stayed empty and
  9. the Statistics page's date-filtered energy figure was permanently zero;
  10. * every plug status poll raised on ``last_checked``.
  11. Postgres is the setup Bambuddy recommends for multi-printer installs, so this
  12. was not a corner. Both of these tests fail against the pre-#2539 code.
  13. """
  14. from __future__ import annotations
  15. from types import SimpleNamespace
  16. from unittest.mock import AsyncMock, MagicMock, patch
  17. import pytest
  18. from backend.app.services import smart_plug_manager as manager_module
  19. from backend.app.utils.local_time import to_naive_utc, utcnow_naive
  20. def test_utcnow_naive_carries_no_offset():
  21. now = utcnow_naive()
  22. assert now.tzinfo is None
  23. def test_to_naive_utc_converts_rather_than_truncates():
  24. """An aware datetime in another zone must be *converted* to UTC before the
  25. offset is dropped, not merely stripped — stripping 02:00+02:00 would record
  26. it as 02:00 UTC, an hour of energy attributed to the wrong day.
  27. """
  28. from datetime import datetime, timedelta, timezone
  29. berlin_summer = timezone(timedelta(hours=2))
  30. aware = datetime(2026, 7, 11, 2, 30, tzinfo=berlin_summer)
  31. naive = to_naive_utc(aware)
  32. assert naive.tzinfo is None
  33. assert naive == datetime(2026, 7, 11, 0, 30)
  34. def test_to_naive_utc_passes_through_naive_and_none():
  35. from datetime import datetime
  36. already = datetime(2026, 7, 11, 12, 0)
  37. assert to_naive_utc(already) is already
  38. assert to_naive_utc(None) is None
  39. @pytest.mark.asyncio
  40. async def test_energy_snapshot_is_stamped_with_a_naive_datetime():
  41. """The regression guard, and the one that actually reproduces the bug.
  42. Reproducing the real failure needs a live Postgres, which CI has no reason to
  43. run for this. So catch it one step earlier: intercept the row on its way into
  44. the session and assert the timestamp carries no offset. An aware value here is
  45. exactly what asyncpg rejects with DataError, and what SQLite quietly swallows —
  46. which is why nobody noticed for so long.
  47. A source scan was tried first and was worthless: the aware ``now`` is assigned
  48. on one line and used as ``recorded_at`` on another, so grepping for the two
  49. together sees nothing.
  50. """
  51. added: list[object] = []
  52. class FakeSession:
  53. async def execute(self, *_a, **_kw):
  54. result = MagicMock()
  55. result.scalars.return_value.all.return_value = [SimpleNamespace(id=1, plug_type="rest", enabled=True)]
  56. return result
  57. def add(self, obj):
  58. added.append(obj)
  59. async def commit(self):
  60. pass
  61. async def __aenter__(self):
  62. return self
  63. async def __aexit__(self, *_a):
  64. return False
  65. manager = manager_module.SmartPlugManager()
  66. with (
  67. patch("backend.app.core.database.async_session", FakeSession),
  68. patch.object(
  69. manager,
  70. "get_service_for_plug",
  71. new=AsyncMock(return_value=SimpleNamespace(get_energy=AsyncMock(return_value={"total": 2.62}))),
  72. ),
  73. ):
  74. await manager._capture_energy_snapshots()
  75. assert len(added) == 1, "expected one snapshot row to be written"
  76. recorded_at = added[0].recorded_at
  77. assert recorded_at.tzinfo is None, (
  78. "energy snapshot stamped with a timezone-aware datetime. The column is "
  79. "naive, and asyncpg raises DataError on Postgres — the whole capture "
  80. "fails and the Statistics energy figure stays at zero. Use utcnow_naive()."
  81. )