From 225c88cf2af95ccc148fc51c9f539cc3070472b0 Mon Sep 17 00:00:00 2001 From: axisrow Date: Wed, 22 Jul 2026 14:20:24 +0800 Subject: [PATCH] fix(dialog_cache): read cached_at as UTC-aware to avoid naive/aware TypeError (#1291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dialog_cache.cached_at` has a schema DEFAULT of `datetime('now')`, which yields a naive string without a UTC offset. Rows inserted without an explicit cached_at therefore reached `get_cached_at` as naive datetimes, and `_get_db_cached_dialogs` (pool_dialogs) crashed on `datetime.now(timezone.utc) - cached_at` with TypeError. Fix at the row→model boundary: parse through `parse_utc_datetime`, which normalizes naive values to UTC. Latent — the normal write path already emits aware isoformat strings, so this only triggers when the schema DEFAULT fills cached_at. Regression tests: - repo-level: schema-DEFAULT row reads back UTC-aware; normal write path preserves time without drift. - pool_dialogs e2e: real DB + schema-DEFAULT row no longer raises on age subtraction (proven red on pre-fix code with the exact TypeError). Co-Authored-By: Claude --- src/database/repositories/dialog_cache.py | 11 +++- .../test_dialog_cache_repository.py | 60 +++++++++++++++++++ .../test_main_client_pool_collector_paths.py | 31 ++++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 tests/repositories/test_dialog_cache_repository.py diff --git a/src/database/repositories/dialog_cache.py b/src/database/repositories/dialog_cache.py index b3057211..e215c357 100644 --- a/src/database/repositories/dialog_cache.py +++ b/src/database/repositories/dialog_cache.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from src.database.pool import ReadConnection -from src.utils.datetime import parse_datetime +from src.utils.datetime import parse_utc_datetime if TYPE_CHECKING: from src.database.facade import Database @@ -129,7 +129,12 @@ async def has_dialogs(self, phone: str) -> bool: return bool(await cur.fetchone()) async def get_cached_at(self, phone: str) -> datetime | None: - """Время самого свежего кэшированного диалога аккаунта (для оценки устаревания).""" + """Время самого свежего кэшированного диалога аккаунта (для оценки устаревания). + + Всегда UTC-aware: строки, вставленные без явного `cached_at`, получают его + из схемного DEFAULT `datetime('now')` — naive-значение без офсета, вычитание + которого из aware-времени падало бы с TypeError. + """ cur = await self._db.execute( "SELECT MAX(cached_at) AS cached_at FROM dialog_cache WHERE phone = ?", (phone,), @@ -137,7 +142,7 @@ async def get_cached_at(self, phone: str) -> datetime | None: row = await cur.fetchone() if not row: return None - return parse_datetime(row["cached_at"]) + return parse_utc_datetime(row["cached_at"]) async def get_all_phones(self) -> list[str]: """Return all distinct phone numbers that have entries in dialog_cache.""" diff --git a/tests/repositories/test_dialog_cache_repository.py b/tests/repositories/test_dialog_cache_repository.py new file mode 100644 index 00000000..c5265eb3 --- /dev/null +++ b/tests/repositories/test_dialog_cache_repository.py @@ -0,0 +1,60 @@ +"""Регресс-гард на naive/aware границу row→model в кэше диалогов (#1291). + +`dialog_cache.cached_at` имеет схемный DEFAULT `datetime('now')`, дающий +naive-строку без офсета. `get_cached_at` раньше отдавал её как есть, и +`pool_dialogs._get_db_cached_dialogs` падал на +`datetime.now(timezone.utc) - cached_at` с TypeError. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from src.database import Database + + +@pytest.mark.anyio +async def test_get_cached_at_is_utc_aware_for_schema_default_row(tmp_path): + """Строка, вставленная без явного cached_at, читается как UTC-aware (#1291).""" + db = Database(str(tmp_path / "test.db")) + await db.initialize() + try: + await db.execute_write( + """ + INSERT INTO dialog_cache (phone, dialog_id, title, channel_type) + VALUES (?, ?, ?, ?) + """, + ("+70001", 1001, "Default cached_at", "channel"), + ) + + cached_at = await db.repos.dialog_cache.get_cached_at("+70001") + + assert cached_at is not None + assert cached_at.tzinfo is not None + # Арифметика из pool_dialogs._get_db_cached_dialogs не должна падать. + age_sec = (datetime.now(timezone.utc) - cached_at).total_seconds() + assert age_sec >= 0 + finally: + await db.close() + + +@pytest.mark.anyio +async def test_get_cached_at_preserves_aware_value_from_normal_write_path(tmp_path): + """Обычный путь записи (aware isoformat) читается без сдвига времени (#1291).""" + db = Database(str(tmp_path / "test.db")) + await db.initialize() + try: + await db.repos.dialog_cache.replace_dialogs( + "+70002", + [{"channel_id": 2002, "title": "Aware", "channel_type": "channel"}], + ) + + cached_at = await db.repos.dialog_cache.get_cached_at("+70002") + + assert cached_at is not None + assert cached_at.tzinfo is not None + assert abs((datetime.now(timezone.utc) - cached_at).total_seconds()) < 60 + finally: + await db.close() diff --git a/tests/test_main_client_pool_collector_paths.py b/tests/test_main_client_pool_collector_paths.py index a1790cf3..3bd7c859 100644 --- a/tests/test_main_client_pool_collector_paths.py +++ b/tests/test_main_client_pool_collector_paths.py @@ -375,6 +375,37 @@ async def test_get_db_cached_dialogs_channels_only(self): assert result is not None assert len(result) == 1 # dm filtered out + @pytest.mark.anyio + async def test_get_db_cached_dialogs_survives_naive_cached_at(self, tmp_path): + """Regression #1291: schema DEFAULT datetime('now') gives naive cached_at; + _get_db_cached_dialogs must not raise TypeError on age subtraction.""" + from src.database import Database + from src.telegram.client_pool import ClientPool + + db = Database(str(tmp_path / "test.db")) + await db.initialize() + try: + # Insert a row that relies on the schema DEFAULT for cached_at → naive. + await db.execute_write( + """ + INSERT INTO dialog_cache (phone, dialog_id, title, channel_type) + VALUES (?, ?, ?, ?) + """, + ("+1", 1, "Default cached_at", "channel"), + ) + + pool = ClientPool.__new__(ClientPool) + pool._dialogs_cache = {} + pool._dialogs_cache_ttl_sec = 300 + pool._dialogs_db_cache_ttl_sec = 3600.0 + pool._db = db + + result = await pool._get_db_cached_dialogs("+1", "full") + assert result is not None + assert len(result) == 1 + finally: + await db.close() + @pytest.mark.anyio async def test_get_cached_dialog_found(self): from src.telegram.client_pool import ClientPool