mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-21 15:36:48 +00:00
31513c2ccb
SQLAlchemy's DateTime(timezone=True) is a no-op on SQLite (the backend has no native tz type), so values round-tripped through the DB come back as naive datetimes. The four SQL _row_to_dict helpers were calling .isoformat() directly on those naive values, shipping timezone-less strings like "2026-05-20T06:10:22.970977" out of the API. The browser's new Date(...) then parses them as local time, shifting recent threads in /threads/search by the local UTC offset (about 8h in Asia/Shanghai). Route the four call sites through coerce_iso() instead — it already normalizes naive values as UTC and emits "+00:00" so the wire format always carries tz. No data migration is needed; existing SQLite rows read back via the corrected serializer. PostgreSQL deployments are unaffected because timestamptz preserves tzinfo end-to-end. Closes #3120
107 lines
4.0 KiB
Python
107 lines
4.0 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 deerflow.persistence.feedback import FeedbackRepository
|
|
|
|
repo = FeedbackRepository(await _init_sqlite(tmp_path))
|
|
try:
|
|
record = await repo.create(run_id="r-tz", thread_id="t-tz", rating=1, user_id="u1")
|
|
_assert_tz_aware(record["created_at"], context="feedback.create.created_at")
|
|
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()
|