deer-flow/backend/tests/test_persistence_timezone.py
rayhpeng cb49dd67dc refactor(feedback): move the secondary adapters to app/adapters
Group them by bounded context instead of by technology, one file per
port, and align the directory name with the AWS Prescriptive Guidance
layout (entrypoints / domain-with-ports / adapters).

  app/infra/persistence/feedback.py
    -> app/adapters/feedback/feedback_repository.py   owned persistence
    -> app/adapters/feedback/run_lookup.py            anti-corruption layer

`persistence/` promised a technology-first classification that its own
contents contradicted: RunStoreRunLookup lived there while its docstring
said "no new SQL". Splitting per port makes that distinction structural.

SqlFeedbackRepository and _tz_aware move unchanged -- verified by
comparing their AST against the original rather than by eye. run_lookup.py
additionally gains a RunStore annotation behind TYPE_CHECKING (the module
is imported lazily by the composition root, so this keeps the runtime
import cost at zero), a docstring stating that this context owns no table
and writes no SQL against it, and a TODO recording the condition under
which the body is replaced: when the run context publishes a contract of
its own, the RunLookup port itself does not move.

Each module docstring opens with a fixed marker so the two kinds of
secondary adapter stay greppable:

  grep -rl "anti-corruption layer" app/adapters/

Filenames deliberately carry no sql_ / acl_ prefix: a prefix encodes an
implementation property, so switching storage would force a rename even
though the port -- and therefore the import path -- has not changed. The
class name already carries it. A prefix earns its place once one port has
several production implementations, which is not yet the case here.

app/infra/ held nothing else and is removed.
2026-07-28 17:34:10 +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.adapters.feedback.feedback_repository 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()