194 lines
7.8 KiB
Python
194 lines
7.8 KiB
Python
"""Tests for timestamp conversion helpers in grpc/_mixins/converters.py."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
|
|
from google.protobuf.timestamp_pb2 import Timestamp
|
|
from noteflow.grpc.mixins.converters import (
|
|
datetime_to_epoch_seconds,
|
|
datetime_to_iso_string,
|
|
datetime_to_proto_timestamp,
|
|
epoch_seconds_to_datetime,
|
|
iso_string_to_datetime,
|
|
proto_timestamp_to_datetime,
|
|
)
|
|
|
|
# Test constants for timestamp conversion tests
|
|
TEST_YEAR = 2024
|
|
TEST_MONTH = 6
|
|
TEST_DAY = 15
|
|
TEST_HOUR = 14
|
|
TEST_MINUTE = 30
|
|
TEST_SECOND = 45
|
|
|
|
|
|
class TestDatetimeProtoTimestampConversion:
|
|
"""Test datetime <-> protobuf Timestamp conversions."""
|
|
|
|
def test_datetime_to_proto_timestamp_computes_correct_epoch(self) -> None:
|
|
"""Convert datetime to proto Timestamp with correct epoch seconds."""
|
|
dt = datetime(TEST_YEAR, TEST_MONTH, TEST_DAY, TEST_HOUR, TEST_MINUTE, TEST_SECOND, tzinfo=UTC)
|
|
expected_epoch = 1718461845
|
|
|
|
ts = datetime_to_proto_timestamp(dt)
|
|
|
|
assert ts.seconds == expected_epoch, (
|
|
f"Expected epoch {expected_epoch}, got {ts.seconds}"
|
|
)
|
|
|
|
def test_proto_timestamp_to_datetime_returns_utc(self) -> None:
|
|
"""Convert proto Timestamp to datetime with UTC timezone."""
|
|
ts = Timestamp()
|
|
ts.FromDatetime(datetime(TEST_YEAR, TEST_MONTH, TEST_DAY, TEST_HOUR, TEST_MINUTE, TEST_SECOND, tzinfo=UTC))
|
|
|
|
dt = proto_timestamp_to_datetime(ts)
|
|
|
|
assert dt.tzinfo == UTC, "Returned datetime should have UTC timezone"
|
|
|
|
def test_datetime_proto_timestamp_roundtrip(self) -> None:
|
|
"""Datetime -> proto Timestamp -> datetime roundtrip preserves value."""
|
|
original = datetime(TEST_YEAR, TEST_MONTH, TEST_DAY, TEST_HOUR, TEST_MINUTE, TEST_SECOND, tzinfo=UTC)
|
|
|
|
ts = datetime_to_proto_timestamp(original)
|
|
result = proto_timestamp_to_datetime(ts)
|
|
|
|
# Protobuf timestamps have nanosecond precision, datetime has microsecond
|
|
assert result.year == original.year, "Year should be preserved"
|
|
assert result.month == original.month, "Month should be preserved"
|
|
assert result.day == original.day, "Day should be preserved"
|
|
assert result.hour == original.hour, "Hour should be preserved"
|
|
assert result.minute == original.minute, "Minute should be preserved"
|
|
assert result.second == original.second, "Second should be preserved"
|
|
|
|
|
|
class TestEpochSecondsConversion:
|
|
"""Test epoch seconds <-> datetime conversions."""
|
|
|
|
def test_epoch_seconds_to_datetime_returns_utc(self) -> None:
|
|
"""Convert epoch seconds to datetime with UTC timezone."""
|
|
# 2024-06-15 14:30:45 UTC
|
|
epoch_seconds = 1718458245.0
|
|
|
|
dt = epoch_seconds_to_datetime(epoch_seconds)
|
|
|
|
assert dt.tzinfo == UTC, "Returned datetime should have UTC timezone"
|
|
assert dt.year == TEST_YEAR, f"Expected year {TEST_YEAR}, got {dt.year}"
|
|
|
|
def test_datetime_to_epoch_seconds_computes_correct_value(self) -> None:
|
|
"""Convert datetime to epoch seconds with correct value."""
|
|
dt = datetime(TEST_YEAR, TEST_MONTH, TEST_DAY, TEST_HOUR, TEST_MINUTE, TEST_SECOND, tzinfo=UTC)
|
|
expected_seconds = 1718461845.0
|
|
|
|
seconds = datetime_to_epoch_seconds(dt)
|
|
|
|
assert seconds == expected_seconds, (
|
|
f"Expected {expected_seconds}, got {seconds}"
|
|
)
|
|
|
|
def test_epoch_seconds_roundtrip(self) -> None:
|
|
"""Epoch seconds -> datetime -> epoch seconds roundtrip preserves value."""
|
|
original_seconds = 1718458245.5 # Include fractional seconds
|
|
|
|
dt = epoch_seconds_to_datetime(original_seconds)
|
|
result_seconds = datetime_to_epoch_seconds(dt)
|
|
|
|
# Allow small floating point tolerance
|
|
tolerance = 0.001
|
|
assert abs(result_seconds - original_seconds) < tolerance, (
|
|
f"Expected {original_seconds}, got {result_seconds}"
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
("seconds", "expected_year"),
|
|
[
|
|
pytest.param(0.0, 1970, id="unix_epoch_start"),
|
|
pytest.param(1000000000.0, 2001, id="billion_seconds"),
|
|
pytest.param(1718458245.0, TEST_YEAR, id="recent_date"),
|
|
],
|
|
)
|
|
def test_epoch_seconds_to_datetime_various_values(
|
|
self, seconds: float, expected_year: int
|
|
) -> None:
|
|
"""Convert various epoch seconds values to datetime."""
|
|
dt = epoch_seconds_to_datetime(seconds)
|
|
|
|
assert dt.year == expected_year, f"Expected year {expected_year}, got {dt.year}"
|
|
|
|
|
|
class TestIsoStringConversion:
|
|
"""Test ISO 8601 string <-> datetime conversions."""
|
|
|
|
def test_iso_string_with_z_suffix_parsed_as_utc(self) -> None:
|
|
"""Parse ISO string with Z suffix as UTC datetime."""
|
|
iso_str = "2024-06-15T14:30:45Z"
|
|
|
|
dt = iso_string_to_datetime(iso_str)
|
|
|
|
assert dt.tzinfo == UTC, "Z suffix should be parsed as UTC"
|
|
assert dt.year == TEST_YEAR, f"Expected year {TEST_YEAR}, got {dt.year}"
|
|
assert dt.month == TEST_MONTH, f"Expected month {TEST_MONTH}, got {dt.month}"
|
|
assert dt.day == TEST_DAY, f"Expected day {TEST_DAY}, got {dt.day}"
|
|
assert dt.hour == TEST_HOUR, f"Expected hour {TEST_HOUR}, got {dt.hour}"
|
|
assert dt.minute == TEST_MINUTE, f"Expected minute {TEST_MINUTE}, got {dt.minute}"
|
|
assert dt.second == TEST_SECOND, f"Expected second {TEST_SECOND}, got {dt.second}"
|
|
|
|
def test_iso_string_with_offset_preserved(self) -> None:
|
|
"""Parse ISO string with timezone offset."""
|
|
iso_str = "2024-06-15T14:30:45+05:30"
|
|
|
|
dt = iso_string_to_datetime(iso_str)
|
|
|
|
assert dt.tzinfo is not None, "Timezone should be preserved"
|
|
# +05:30 offset
|
|
expected_offset = timezone(offset=timedelta(hours=5, minutes=30))
|
|
assert dt.utcoffset() == expected_offset.utcoffset(None), (
|
|
"Timezone offset should be preserved"
|
|
)
|
|
|
|
def test_iso_string_without_timezone_defaults_to_utc(self) -> None:
|
|
"""Parse ISO string without timezone, defaulting to UTC."""
|
|
iso_str = "2024-06-15T14:30:45"
|
|
|
|
dt = iso_string_to_datetime(iso_str)
|
|
|
|
assert dt.tzinfo == UTC, "Missing timezone should default to UTC"
|
|
|
|
def test_datetime_to_iso_string_includes_timezone(self) -> None:
|
|
"""Format datetime as ISO string with timezone."""
|
|
dt = datetime(TEST_YEAR, TEST_MONTH, TEST_DAY, TEST_HOUR, TEST_MINUTE, TEST_SECOND, tzinfo=UTC)
|
|
|
|
iso_str = datetime_to_iso_string(dt)
|
|
|
|
assert f"{TEST_YEAR}-0{TEST_MONTH}-{TEST_DAY}" in iso_str, f"Date should be in output: {iso_str}"
|
|
assert f"{TEST_HOUR}:{TEST_MINUTE}:{TEST_SECOND}" in iso_str, f"Time should be in output: {iso_str}"
|
|
# UTC represented as +00:00 in isoformat
|
|
assert "+00:00" in iso_str or "Z" in iso_str, (
|
|
f"Timezone should be in output: {iso_str}"
|
|
)
|
|
|
|
def test_iso_string_roundtrip(self) -> None:
|
|
"""Datetime -> ISO string -> datetime roundtrip preserves value."""
|
|
original = datetime(TEST_YEAR, TEST_MONTH, TEST_DAY, TEST_HOUR, TEST_MINUTE, TEST_SECOND, tzinfo=UTC)
|
|
|
|
iso_str = datetime_to_iso_string(original)
|
|
result = iso_string_to_datetime(iso_str)
|
|
|
|
assert result == original, f"Roundtrip should preserve datetime: {original} -> {iso_str} -> {result}"
|
|
|
|
@pytest.mark.parametrize(
|
|
("iso_str", "expected_hour"),
|
|
[
|
|
pytest.param("2024-06-15T00:00:00Z", 0, id="midnight"),
|
|
pytest.param("2024-06-15T12:00:00Z", 12, id="noon"),
|
|
pytest.param("2024-06-15T23:59:59Z", 23, id="end_of_day"),
|
|
],
|
|
)
|
|
def test_iso_string_various_times(self, iso_str: str, expected_hour: int) -> None:
|
|
"""Parse various ISO string time values."""
|
|
dt = iso_string_to_datetime(iso_str)
|
|
|
|
assert dt.hour == expected_hour, f"Expected hour {expected_hour}, got {dt.hour}"
|