deer-flow/backend/tests/test_utils_time.py
Xinmin Zeng ca3332f8bf
fix(gateway): return ISO 8601 timestamps from threads endpoints (#2599)
* fix(gateway): return ISO 8601 timestamps from threads endpoints (#2594)

ThreadResponse documents created_at / updated_at as ISO timestamps,
matching the LangGraph Platform schema (langgraph_sdk.schema.Thread
exposes them as datetime, JSON-encoded as ISO 8601). The gateway
threads router was instead emitting str(time.time()) — unix-second
floats — breaking frontend new Date() parsing and producing a mixed
ISO/unix wire format that also corrupted the search sort order.

Centralize timestamp generation in deerflow.utils.time:
- now_iso()       — datetime.now(UTC).isoformat()
- coerce_iso(x)   — heals legacy unix-timestamp strings on read so the
                    store converges to ISO without a one-shot migration

threads.py: replace 6 time.time() call sites with now_iso(); wrap all
read paths and Phase-2 checkpoint metadata with coerce_iso(); _store_upsert
opportunistically heals legacy created_at on update; drop unused time import.

thread_runs.py: reuse now_iso() instead of a private duplicate _now_iso(),
preventing future drift between the two timestamp call sites.

Tests: 9 unit tests for the helper; 5 integration tests pinning the ISO
contract for create/get/patch/search and the legacy-healing path on the
internal store upsert. Full suite: 2144 passed, 15 skipped, 0 failed.

Closes #2594

* fix(gateway): coerce checkpoint metadata timestamps to ISO on read

After the merge with main, three additional read paths in ``threads.py``
were still emitting raw ``str(metadata.get("created_at", ""))`` —
``get_thread_state``, ``update_thread_state``, and ``get_thread_history``.

Same root cause as #2594: when the checkpoint metadata's ``created_at``
is a unix-second float (legacy data, or a checkpoint written by an older
Gateway version), ``str(float)`` produces ``"1777252410.411327"`` and the
frontend's ``new Date(...)`` returns ``Invalid Date``. The fix on the
``/threads/{id}`` GET path was already in place; these three sibling
endpoints needed the same treatment.

All four call sites now flow through ``coerce_iso``, so:
- legacy float metadata heals to ISO on the way out,
- ISO metadata passes through unchanged,
- ``datetime`` instances (which the new ``coerce_iso`` branch handles
  explicitly) emit with the ``T`` separator instead of falling through
  to the space-separated ``str(datetime)`` form.

Coverage added for the two endpoints not already pinned by the merge:
- ``test_get_thread_state_returns_iso_for_legacy_checkpoint_metadata``
- ``test_get_thread_history_returns_iso_for_legacy_checkpoint_metadata``

Both pre-seed a checkpoint whose metadata carries the literal float
from the issue body and assert the wire format is ISO.
2026-05-02 15:16:16 +08:00

91 lines
2.9 KiB
Python

"""Tests for ``deerflow.utils.time``."""
from __future__ import annotations
import re
from datetime import UTC, datetime, timedelta, timezone
from deerflow.utils.time import coerce_iso, now_iso
_ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
def test_now_iso_is_utc_iso8601() -> None:
value = now_iso()
assert _ISO_RE.match(value), value
parsed = datetime.fromisoformat(value)
assert parsed.tzinfo is not None
assert parsed.tzinfo.utcoffset(parsed) == UTC.utcoffset(parsed)
def test_coerce_iso_passes_iso_through() -> None:
iso = "2026-04-27T01:13:30.411334+00:00"
assert coerce_iso(iso) == iso
def test_coerce_iso_converts_unix_float_string() -> None:
legacy = "1777252410.411327"
out = coerce_iso(legacy)
assert _ISO_RE.match(out), out
# Round-trip: parsed timestamp matches the original epoch.
parsed = datetime.fromisoformat(out)
assert abs(parsed.timestamp() - 1777252410.411327) < 1e-3
def test_coerce_iso_converts_unix_int_string() -> None:
out = coerce_iso("1700000000")
assert _ISO_RE.match(out), out
def test_coerce_iso_converts_numeric_types() -> None:
out_float = coerce_iso(1777252410.411327)
out_int = coerce_iso(1700000000)
assert _ISO_RE.match(out_float)
assert _ISO_RE.match(out_int)
def test_coerce_iso_handles_empty_and_none() -> None:
assert coerce_iso(None) == ""
assert coerce_iso("") == ""
def test_coerce_iso_does_not_misinterpret_short_numeric() -> None:
# A 4-digit year should never be parsed as a unix timestamp; only
# 10-digit unix-second strings match the legacy pattern.
assert coerce_iso("2026") == "2026"
def test_coerce_iso_handles_unparseable_string() -> None:
assert coerce_iso("not-a-timestamp") == "not-a-timestamp"
def test_coerce_iso_rejects_bool() -> None:
# ``bool`` is a subclass of ``int`` — must not be treated as epoch 0/1.
assert coerce_iso(True) == "True"
assert coerce_iso(False) == "False"
def test_coerce_iso_handles_tz_aware_datetime() -> None:
# str(datetime) would emit a space separator; coerce_iso must use ``T``.
dt = datetime(2026, 4, 27, 1, 13, 30, 411327, tzinfo=UTC)
out = coerce_iso(dt)
assert out == "2026-04-27T01:13:30.411327+00:00"
assert "T" in out and " " not in out
def test_coerce_iso_handles_tz_naive_datetime_as_utc() -> None:
dt = datetime(2026, 4, 27, 1, 13, 30, 411327)
out = coerce_iso(dt)
assert out == "2026-04-27T01:13:30.411327+00:00"
parsed = datetime.fromisoformat(out)
assert parsed.tzinfo is not None
assert parsed.utcoffset() == timedelta(0)
def test_coerce_iso_normalises_non_utc_datetime_to_utc() -> None:
# +08:00 wall-clock 09:13 == UTC 01:13.
plus_eight = timezone(timedelta(hours=8))
dt = datetime(2026, 4, 27, 9, 13, 30, 411327, tzinfo=plus_eight)
out = coerce_iso(dt)
assert out == "2026-04-27T01:13:30.411327+00:00"