deer-flow/backend/tests/test_persistence_timezone.py
rayhpeng 808e31e2ee refactor(harness): drop legacy feedback repository
The ORM row stays behind (single alembic chain until the infrastructure
move); everything else is served by the domain slice. Repository tests
become a contract suite that runs the same cases against the SQL adapter
and an in-memory fake; ownership and timezone tests use explicit user_id
instead of the AUTO-sentinel context.
2026-07-23 15:30:56 +08:00

112 lines
4.4 KiB
Python

"""Regression tests for #3120: SQLite-backed stores must emit tz-aware ISO timestamps.
SQLAlchemy's ``DateTime(timezone=True)`` is a no-op on SQLite because the
backend has no native timezone type, so values read back are naive
``datetime`` instances. The four SQL ``_row_to_dict`` helpers therefore
have to normalize through :func:`deerflow.utils.time.coerce_iso` instead
of calling ``.isoformat()`` directly; otherwise the API ships
timezone-less strings (e.g. ``"2026-05-20T06:10:22.970977"``) and the
frontend's ``new Date(...)`` parses them as local time, shifting recent
threads by the local UTC offset.
"""
import re
import pytest
_TZ_SUFFIX_RE = re.compile(r"(?:\+\d{2}:\d{2}|Z)$")
def _assert_tz_aware(value: str | None, *, context: str) -> None:
assert value, f"{context}: expected ISO string, got {value!r}"
assert _TZ_SUFFIX_RE.search(value), f"{context}: timestamp lacks tz suffix: {value!r}"
async def _init_sqlite(tmp_path):
from deerflow.persistence.engine import get_session_factory, init_engine
url = f"sqlite+aiosqlite:///{tmp_path / 'tz.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
return get_session_factory()
async def _cleanup():
from deerflow.persistence.engine import close_engine
await close_engine()
@pytest.mark.anyio
async def test_thread_meta_emits_tz_aware_timestamps(tmp_path):
from deerflow.persistence.thread_meta import ThreadMetaRepository
repo = ThreadMetaRepository(await _init_sqlite(tmp_path))
try:
created = await repo.create("t-tz", user_id="u1", display_name="tz")
_assert_tz_aware(created["created_at"], context="thread_meta.create.created_at")
_assert_tz_aware(created["updated_at"], context="thread_meta.create.updated_at")
# Second read from DB exercises the same _row_to_dict path on a
# value that SQLite has round-tripped (where tzinfo is lost).
fetched = await repo.get("t-tz", user_id="u1")
_assert_tz_aware(fetched["created_at"], context="thread_meta.get.created_at")
_assert_tz_aware(fetched["updated_at"], context="thread_meta.get.updated_at")
listed = await repo.search(user_id="u1")
assert listed, "search must return the created row"
_assert_tz_aware(listed[0]["created_at"], context="thread_meta.search.created_at")
_assert_tz_aware(listed[0]["updated_at"], context="thread_meta.search.updated_at")
finally:
await _cleanup()
@pytest.mark.anyio
async def test_run_repository_emits_tz_aware_timestamps(tmp_path):
from deerflow.persistence.run import RunRepository
repo = RunRepository(await _init_sqlite(tmp_path))
try:
await repo.put("r-tz", thread_id="t-tz", user_id="u1")
row = await repo.get("r-tz", user_id="u1")
_assert_tz_aware(row["created_at"], context="run.get.created_at")
_assert_tz_aware(row["updated_at"], context="run.get.updated_at")
finally:
await _cleanup()
@pytest.mark.anyio
async def test_feedback_repository_emits_tz_aware_timestamps(tmp_path):
from app.infra.persistence.feedback import SqlFeedbackRepository
from deerflow.domain.feedback import Feedback
repo = SqlFeedbackRepository(await _init_sqlite(tmp_path))
try:
fb = await repo.save(Feedback.create(run_id="r-tz", thread_id="t-tz", rating=1, user_id="u1"))
# The port exchanges domain objects (datetime), not ISO strings;
# serialization to ISO happens in the router. Assert tz-awareness here.
assert fb.created_at.tzinfo is not None, "feedback.save.created_at lacks tzinfo"
read_back = (await repo.latest_per_run_in_thread("t-tz", user_id="u1"))["r-tz"]
assert read_back.created_at.tzinfo is not None, "feedback read-back lacks tzinfo"
finally:
await _cleanup()
@pytest.mark.anyio
async def test_run_event_store_emits_tz_aware_timestamps(tmp_path):
from deerflow.runtime.events.store.db import DbRunEventStore
store = DbRunEventStore(await _init_sqlite(tmp_path))
try:
await store.put(
thread_id="t-tz",
run_id="r-tz",
event_type="log",
category="log",
content="hello",
)
events = await store.list_events("t-tz", "r-tz", user_id=None)
assert events, "expected at least one event"
_assert_tz_aware(events[0]["created_at"], context="run_event.list.created_at")
finally:
await _cleanup()