Research-Stack/5-Applications/nodupe/tests/plugins/test_time_sync.py
2026-05-05 21:15:26 -05:00

663 lines
25 KiB
Python

"""
Tests for TimeSync Tool
Tests the NTP-based time synchronization and FastDate64 timestamp encoding functionality.
These tests mock network operations to avoid real UDP calls and ensure reliable testing.
"""
import time
import threading
from unittest.mock import patch, MagicMock
import pytest
from datetime import datetime, timezone
from nodupe.tools.time_sync import TimeSyncTool
class TestTimeSyncTool:
"""Test suite for TimeSyncTool."""
def test_tool_metadata(self):
"""Test that tool metadata is correctly defined."""
tool = TimeSyncTool()
metadata = tool.metadata
assert metadata.name == "TimeSync"
assert metadata.version == "1.0.0"
assert "NTP-based" in metadata.description
assert "FastDate64" in metadata.description
assert "time" in metadata.tags
assert "ntp" in metadata.tags
def test_initialization_defaults(self):
"""Test tool initialization with default values."""
tool = TimeSyncTool()
assert tool.servers == ["time.google.com", "time.cloudflare.com", "pool.ntp.org"]
assert tool.timeout == 3.0
assert tool.attempts == 2
assert tool.max_acceptable_delay == 0.5
assert tool.alpha == 0.3
def test_initialization_custom_values(self):
"""Test tool initialization with custom values."""
tool = TimeSyncTool(
servers=["custom.ntp.com"],
timeout=5.0,
attempts=3,
max_acceptable_delay=1.0,
smoothing_alpha=0.5
)
assert tool.servers == ["custom.ntp.com"]
assert tool.timeout == 5.0
assert tool.attempts == 3
assert tool.max_acceptable_delay == 1.0
assert tool.alpha == 0.5
def test_runtime_flags_initial_state(self):
"""Test initial state of runtime flags."""
tool = TimeSyncTool()
# These depend on environment variables, so we just check they're booleans
assert isinstance(tool.is_enabled(), bool)
assert isinstance(tool.is_network_allowed(), bool)
assert isinstance(tool.is_background_allowed(), bool)
def test_enable_disable_tool(self):
"""Test enabling and disabling the tool."""
tool = TimeSyncTool(enabled=False)
assert not tool.is_enabled()
tool.enable()
assert tool.is_enabled()
tool.disable()
assert not tool.is_enabled()
def test_network_operations_control(self):
"""Test enabling and disabling network operations."""
tool = TimeSyncTool(allow_network=False)
assert not tool.is_network_allowed()
tool.enable_network()
assert tool.is_network_allowed()
tool.disable_network()
assert not tool.is_network_allowed()
def test_background_sync_control(self):
"""Test enabling and disabling background synchronization."""
tool = TimeSyncTool(allow_background=False)
assert not tool.is_background_allowed()
tool.enable_background()
assert tool.is_background_allowed()
tool.disable_background()
assert not tool.is_background_allowed()
def test_encode_decode_fastdate64_roundtrip(self):
"""Test FastDate64 encoding and decoding roundtrip."""
# Test with various timestamps
test_timestamps = [
1672531200.123456, # 2023-01-01T00:00:00.123456Z
0.0, # Unix epoch
1000000000.0, # 2001-09-09T01:46:40Z
time.time(), # Current time
]
for ts in test_timestamps:
encoded = TimeSyncTool.encode_fastdate64(ts)
decoded = TimeSyncTool.decode_fastdate64(encoded)
# Allow small rounding error due to fractional truncation
assert abs(decoded - ts) < 1e-6, f"Roundtrip failed for {ts}"
def test_encode_fastdate64_negative_timestamp(self):
"""Test that negative timestamps raise ValueError."""
with pytest.raises(ValueError, match="Negative timestamps not supported"):
TimeSyncTool.encode_fastdate64(-1.0)
def test_encode_fastdate64_overflow(self):
"""Test that timestamps too large for encoding raise OverflowError."""
# Use a timestamp that would exceed FASTDATE_SECONDS_BITS
large_ts = (1 << 34) # Exceeds 34-bit seconds field
with pytest.raises(OverflowError, match="too large for.*bit field"):
TimeSyncTool.encode_fastdate64(large_ts)
def test_fastdate64_to_iso_conversion(self):
"""Test FastDate64 to ISO 8601 string conversion."""
ts = 1672531200.123456 # 2023-01-01T00:00:00.123456Z
encoded = TimeSyncTool.encode_fastdate64(ts)
iso_string = TimeSyncTool.fastdate64_to_iso(encoded)
# Parse the ISO string back to timestamp
dt = datetime.fromisoformat(iso_string)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
decoded_ts = dt.timestamp()
assert abs(decoded_ts - ts) < 1e-6
def test_iso_to_fastdate64_conversion(self):
"""Test ISO 8601 string to FastDate64 conversion."""
iso_string = "2023-01-01T00:00:00.123456+00:00"
encoded = TimeSyncTool.iso_to_fastdate64(iso_string)
decoded = TimeSyncTool.decode_fastdate64(encoded)
# Parse the original ISO string to timestamp
dt = datetime.fromisoformat(iso_string)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
original_ts = dt.timestamp()
assert abs(decoded - original_ts) < 1e-6
def test_disabled_behavior_fallback(self):
"""Test that disabled tool falls back to time.monotonic()."""
tool = TimeSyncTool(enabled=False)
# Should not raise and should return a float
result = tool.get_corrected_time()
assert isinstance(result, float)
# Should return a valid FastDate64 encoded timestamp
fast64 = tool.get_corrected_fast64()
assert isinstance(fast64, int)
# Should raise when trying to sync
with pytest.raises(TimeSyncTool._get_exception_class()):
tool.force_sync()
@patch('nodupe.tools.time_sync.socket.getaddrinfo')
@patch('nodupe.tools.time_sync.TimeSyncTool._query_address')
def test_force_sync_success(self, mock_query_address, mock_getaddrinfo):
"""Test successful NTP synchronization."""
# Mock DNS resolution
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_DGRAM, 17, '', ('1.1.1.1', 123))
]
# Mock NTP response
server_time = 1600000000.5
offset = 0.25
delay = 0.01
mock_query_address.return_value = (server_time, offset, delay)
tool = TimeSyncTool(servers=["test.ntp.com"], enabled=True, allow_network=True)
result = tool.force_sync()
assert result[0] == "test.ntp.com"
assert result[1] == server_time
assert result[2] == offset
assert result[3] == delay
# Verify that internal state was updated
assert tool.get_offset_estimate() is not None
assert tool.get_last_delay() == delay
@patch('nodupe.tools.time_sync.socket.getaddrinfo')
def test_force_sync_disabled_network(self, mock_getaddrinfo):
"""Test that force_sync raises when network is disabled."""
tool = TimeSyncTool(allow_network=False)
with pytest.raises(TimeSyncTool._get_exception_class(), match="Network operations are disabled"):
tool.force_sync()
@patch('nodupe.tools.time_sync.socket.getaddrinfo')
def test_force_sync_disabled_tool(self, mock_getaddrinfo):
"""Test that force_sync raises when tool is disabled."""
tool = TimeSyncTool(enabled=False)
with pytest.raises(TimeSyncTool._get_exception_class(), match="TimeSync instance is disabled"):
tool.force_sync()
@patch('nodupe.tools.time_sync.socket.getaddrinfo')
@patch('nodupe.tools.time_sync.TimeSyncTool._query_address')
def test_force_sync_high_delay(self, mock_query_address, mock_getaddrinfo):
"""Test that force_sync raises when delay exceeds threshold."""
# Mock DNS resolution
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_DGRAM, 17, '', ('1.1.1.1', 123))
]
# Mock NTP response with high delay
server_time = 1600000000.5
offset = 0.25
delay = 1.0 # Exceeds max_acceptable_delay of 0.5
mock_query_address.return_value = (server_time, offset, delay)
tool = TimeSyncTool(servers=["test.ntp.com"], enabled=True, allow_network=True)
with pytest.raises(RuntimeError, match="too noisy"):
tool.force_sync()
@patch('nodupe.tools.time_sync.socket.getaddrinfo')
@patch('nodupe.tools.time_sync.TimeSyncTool._query_address')
def test_maybe_sync_success(self, mock_query_address, mock_getaddrinfo):
"""Test successful maybe_sync operation."""
# Mock DNS resolution
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_DGRAM, 17, '', ('1.1.1.1', 123))
]
# Mock NTP response
server_time = 1600000000.5
offset = 0.25
delay = 0.01
mock_query_address.return_value = (server_time, offset, delay)
tool = TimeSyncTool(servers=["test.ntp.com"], enabled=True, allow_network=True)
result = tool.maybe_sync()
assert result is not None
assert result[1] == server_time
def test_maybe_sync_disabled(self):
"""Test maybe_sync returns None when disabled."""
tool = TimeSyncTool(enabled=False)
result = tool.maybe_sync()
assert result is None
@patch('nodupe.tools.time_sync.socket.getaddrinfo')
@patch('nodupe.tools.time_sync.TimeSyncTool._query_address')
def test_maybe_sync_failure(self, mock_query_address, mock_getaddrinfo):
"""Test maybe_sync returns None on failure."""
# Mock DNS resolution failure
mock_getaddrinfo.return_value = []
tool = TimeSyncTool(servers=["test.ntp.com"], enabled=True, allow_network=True)
result = tool.maybe_sync()
assert result is None
@patch('nodupe.tools.time_sync.socket.getaddrinfo')
@patch('nodupe.tools.time_sync.TimeSyncTool._query_address')
def test_background_sync_start_stop(self, mock_query_address, mock_getaddrinfo):
"""Test starting and stopping background synchronization."""
# Mock DNS resolution and NTP response
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_DGRAM, 17, '', ('1.1.1.1', 123))
]
mock_query_address.return_value = (1600000000.5, 0.25, 0.01)
tool = TimeSyncTool(
servers=["test.ntp.com"],
enabled=True,
allow_network=True,
allow_background=True
)
# Start background sync
tool.start_background(interval=1.0, initial_sync=False)
# Give thread time to start
time.sleep(0.1)
assert tool._bg_thread is not None
assert tool._bg_thread.is_alive()
# Stop background sync
tool.stop_background(wait=True)
assert tool._bg_thread is None
def test_background_sync_disabled_network(self):
"""Test that background sync cannot start when network is disabled."""
tool = TimeSyncTool(allow_network=False, allow_background=True)
with pytest.raises(TimeSyncTool._get_exception_class(), match="Cannot start background sync"):
tool.start_background()
def test_background_sync_disabled_background(self):
"""Test that background sync cannot start when background is disabled."""
tool = TimeSyncTool(allow_network=True, allow_background=False)
with pytest.raises(TimeSyncTool._get_exception_class(), match="Background syncing is disabled"):
tool.start_background()
def test_get_corrected_time_no_sync(self):
"""Test get_corrected_time returns fallback when not synced."""
tool = TimeSyncTool(enabled=True)
# Should return time.monotonic() when not synced
result = tool.get_corrected_time()
assert isinstance(result, float)
@patch('nodupe.tools.time_sync.socket.getaddrinfo')
@patch('nodupe.tools.time_sync.TimeSyncTool._query_address')
def test_get_corrected_time_after_sync(self, mock_query_address, mock_getaddrinfo):
"""Test get_corrected_time returns corrected time after sync."""
# Mock DNS resolution
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_DGRAM, 17, '', ('1.1.1.1', 123))
]
# Mock NTP response
server_time = 1600000000.5
offset = 0.25
delay = 0.01
mock_query_address.return_value = (server_time, offset, delay)
tool = TimeSyncTool(servers=["test.ntp.com"], enabled=True, allow_network=True)
tool.force_sync()
# Get corrected time
corrected_time = tool.get_corrected_time()
# Should be close to server time plus elapsed monotonic time
assert isinstance(corrected_time, float)
assert corrected_time > server_time
def test_get_status(self):
"""Test get_status returns comprehensive status information."""
tool = TimeSyncTool(
servers=["test.ntp.com"],
timeout=5.0,
attempts=3,
max_acceptable_delay=1.0,
smoothing_alpha=0.5,
enabled=True,
allow_network=True,
allow_background=True
)
status = tool.get_status()
expected_keys = [
"enabled", "network_allowed", "background_allowed", "background_running",
"base_server_time", "base_monotonic", "smoothed_offset", "last_delay",
"servers", "timeout", "attempts", "max_acceptable_delay", "smoothing_alpha"
]
for key in expected_keys:
assert key in status
assert status["servers"] == ["test.ntp.com"]
assert status["timeout"] == 5.0
assert status["attempts"] == 3
assert status["max_acceptable_delay"] == 1.0
assert status["smoothing_alpha"] == 0.5
def test_convenience_methods(self):
"""Test convenience methods work correctly."""
tool = TimeSyncTool(enabled=False)
# These should work without raising
timestamp = tool.get_timestamp()
fast64 = tool.get_timestamp_fast64()
assert isinstance(timestamp, float)
assert isinstance(fast64, int)
def test_smoothing_alpha_applied(self):
"""Test that smoothing alpha is applied to offset calculations."""
tool = TimeSyncTool(smoothing_alpha=0.1, enabled=True, allow_network=True)
# Mock multiple syncs with different offsets
with patch('nodupe.tools.time_sync.socket.getaddrinfo') as mock_getaddrinfo, \
patch('nodupe.tools.time_sync.TimeSyncTool._query_address') as mock_query_address:
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_DGRAM, 17, '', ('1.1.1.1', 123))
]
# First sync with offset 1.0
mock_query_address.return_value = (1600000000.0, 1.0, 0.01)
tool.force_sync()
first_offset = tool.get_offset_estimate()
# Second sync with offset 2.0
mock_query_address.return_value = (1600000000.0, 2.0, 0.01)
tool.force_sync()
second_offset = tool.get_offset_estimate()
# Should be smoothed: 0.1 * 2.0 + 0.9 * 1.0 = 1.1
expected = 0.1 * 2.0 + 0.9 * 1.0
assert abs(second_offset - expected) < 1e-6
def test_tool_lifecycle(self):
"""Test tool initialization and shutdown lifecycle."""
tool = TimeSyncTool(enabled=True)
# Mock successful sync during initialization
with patch.object(tool, 'force_sync') as mock_sync:
tool.initialize()
mock_sync.assert_called_once()
# Shutdown should stop background thread
with patch.object(tool, 'stop_background') as mock_stop:
tool.shutdown()
mock_stop.assert_called_once_with(wait=False)
def test_get_authenticated_time_iso8601_format(self):
"""Test get_authenticated_time with ISO-8601 format."""
tool = TimeSyncTool(enabled=True)
# Mock sync_with_fallback to return a known time
with patch.object(tool, 'sync_with_fallback') as mock_sync, \
patch.object(tool, 'get_corrected_time') as mock_get_time:
mock_sync.return_value = ("ntp", 1600000000.0, 0.0, 0.01)
mock_get_time.return_value = 1600000000.123456
result = tool.get_authenticated_time()
# Should return ISO-8601 format
assert result == "2020-09-13T12:26:40.123456Z"
assert isinstance(result, str)
def test_get_authenticated_time_unix_format(self):
"""Test get_authenticated_time with Unix timestamp format."""
tool = TimeSyncTool(enabled=True)
# Mock sync_with_fallback to return a known time
with patch.object(tool, 'sync_with_fallback') as mock_sync, \
patch.object(tool, 'get_corrected_time') as mock_get_time:
mock_sync.return_value = ("ntp", 1600000000.0, 0.0, 0.01)
mock_get_time.return_value = 1600000000.123456
result = tool.get_authenticated_time(format="unix")
# Should return Unix timestamp as string
assert result == "1600000000.123456"
assert isinstance(result, str)
def test_get_authenticated_time_rfc3339_format(self):
"""Test get_authenticated_time with RFC-3339 format."""
tool = TimeSyncTool(enabled=True)
# Mock sync_with_fallback to return a known time
with patch.object(tool, 'sync_with_fallback') as mock_sync, \
patch.object(tool, 'get_corrected_time') as mock_get_time:
mock_sync.return_value = ("ntp", 1600000000.0, 0.0, 0.01)
mock_get_time.return_value = 1600000000.123456
result = tool.get_authenticated_time(format="rfc3339")
# Should return RFC-3339 format (same as ISO-8601)
assert result == "2020-09-13T12:26:40.123456Z"
def test_get_authenticated_time_human_format(self):
"""Test get_authenticated_time with human-readable format."""
tool = TimeSyncTool(enabled=True)
# Mock sync_with_fallback to return a known time
with patch.object(tool, 'sync_with_fallback') as mock_sync, \
patch.object(tool, 'get_corrected_time') as mock_get_time:
mock_sync.return_value = ("ntp", 1600000000.0, 0.0, 0.01)
mock_get_time.return_value = 1600000000.123456
result = tool.get_authenticated_time(format="human")
# Should return human-readable format
assert result == "2020-09-13 12:26:40.123456 UTC"
def test_get_authenticated_time_disabled_tool(self):
"""Test get_authenticated_time raises error when tool is disabled."""
tool = TimeSyncTool(enabled=False)
with pytest.raises(TimeSyncTool._get_exception_class(), match="TimeSync instance is disabled"):
tool.get_authenticated_time()
def test_get_authenticated_time_unsupported_format(self):
"""Test get_authenticated_time raises error for unsupported formats."""
tool = TimeSyncTool(enabled=True)
with pytest.raises(ValueError, match="Unsupported format"):
tool.get_authenticated_time(format="invalid")
def test_get_authenticated_time_fallback_warning(self):
"""Test that fallback to RTC triggers appropriate warning."""
tool = TimeSyncTool(enabled=True, allow_network=False)
# Mock sync_with_fallback to return RTC fallback
with patch.object(tool, 'sync_with_fallback') as mock_sync, \
patch.object(tool, 'get_corrected_time') as mock_get_time, \
patch('nodupe.tools.time_sync.logger') as mock_logger:
mock_sync.return_value = ("rtc", 1600000000.0, 0.0, 0.0)
mock_get_time.return_value = 1600000000.123456
result = tool.get_authenticated_time()
# Should return the time
assert result == "2020-09-13T12:26:40.123456Z"
# Should log warning about fallback
mock_logger.warning.assert_called_once_with(
"Time obtained via rtc fallback (not NTP/NTS). Time may have slight drift from network time."
)
def test_get_authenticated_time_monotonic_fallback(self):
"""Test get_authenticated_time with monotonic fallback."""
tool = TimeSyncTool(enabled=True, allow_network=False)
# Mock sync_with_fallback to return monotonic fallback
with patch.object(tool, 'sync_with_fallback') as mock_sync, \
patch.object(tool, 'get_corrected_time') as mock_get_time, \
patch('nodupe.tools.time_sync.logger') as mock_logger:
mock_sync.return_value = ("monotonic", 1600000000.0, 0.0, 0.0)
mock_get_time.return_value = 1600000000.123456
result = tool.get_authenticated_time()
# Should return the time
assert result == "2020-09-13T12:26:40.123456Z"
# Should log warning about fallback
mock_logger.warning.assert_called_once_with(
"Time obtained via monotonic fallback (not NTP/NTS). Time may have slight drift from network time."
)
def test_get_authenticated_time_precision(self):
"""Test that get_authenticated_time maintains microsecond precision."""
tool = TimeSyncTool(enabled=True)
# Test with various microsecond values
test_times = [
1600000000.000000, # No fractional part
1600000000.123456, # 6-digit fractional
1600000000.100000, # 1-digit fractional
1600000000.000001, # 1-microsecond
]
for test_time in test_times:
with patch.object(tool, 'sync_with_fallback') as mock_sync, \
patch.object(tool, 'get_corrected_time') as mock_get_time:
mock_sync.return_value = ("ntp", test_time, 0.0, 0.01)
mock_get_time.return_value = test_time
result = tool.get_authenticated_time()
# Parse the ISO string back to timestamp
dt = datetime.fromisoformat(result.replace("Z", "+00:00"))
decoded_time = dt.timestamp()
# Should maintain microsecond precision
assert abs(decoded_time - test_time) < 1e-6
def test_get_authenticated_time_utc_timezone(self):
"""Test that get_authenticated_time always returns UTC time."""
tool = TimeSyncTool(enabled=True)
# Mock sync_with_fallback to return a known time
with patch.object(tool, 'sync_with_fallback') as mock_sync, \
patch.object(tool, 'get_corrected_time') as mock_get_time:
mock_sync.return_value = ("ntp", 1600000000.0, 0.0, 0.01)
mock_get_time.return_value = 1600000000.123456
result = tool.get_authenticated_time()
# Should end with Z (UTC indicator)
assert result.endswith("Z")
# Parse and verify timezone is UTC
dt = datetime.fromisoformat(result.replace("Z", "+00:00"))
assert dt.tzinfo is not None
assert dt.tzinfo.utcoffset(dt).total_seconds() == 0
def test_get_authenticated_time_network_failure_fallback(self):
"""Test get_authenticated_time handles network failures gracefully."""
tool = TimeSyncTool(enabled=True, allow_network=True)
# Mock sync_with_fallback to simulate network failure and RTC fallback
with patch.object(tool, 'sync_with_fallback') as mock_sync, \
patch.object(tool, 'get_corrected_time') as mock_get_time, \
patch('nodupe.tools.time_sync.logger') as mock_logger:
# Simulate network failure, fallback to RTC
mock_sync.return_value = ("rtc", 1600000000.0, 0.0, 0.0)
mock_get_time.return_value = 1600000000.123456
result = tool.get_authenticated_time()
# Should still return a valid time
assert result == "2020-09-13T12:26:40.123456Z"
# Should log warning about fallback
mock_logger.warning.assert_called_once()
def test_get_authenticated_time_case_insensitive_formats(self):
"""Test that get_authenticated_time handles case-insensitive format strings."""
tool = TimeSyncTool(enabled=True)
# Mock sync_with_fallback to return a known time
with patch.object(tool, 'sync_with_fallback') as mock_sync, \
patch.object(tool, 'get_corrected_time') as mock_get_time:
mock_sync.return_value = ("ntp", 1600000000.0, 0.0, 0.01)
mock_get_time.return_value = 1600000000.123456
# Test various case combinations
test_formats = ["ISO8601", "Iso8601", "ISO", "iso", "RFC3339", "Rfc3339", "rfc", "UNIX", "Unix", "unix", "HUMAN", "Human", "human"]
for fmt in test_formats:
result = tool.get_authenticated_time(format=fmt)
if fmt.lower() in ["iso8601", "iso", "rfc3339", "rfc"]:
assert result == "2020-09-13T12:26:40.123456Z"
elif fmt.lower() in ["unix", "unix"]:
assert result == "1600000000.123456"
elif fmt.lower() in ["human", "human"]:
assert result == "2020-09-13 12:26:40.123456 UTC"
# Helper function to get the actual exception class
def _get_exception_class():
"""Helper to get the actual TimeSyncDisabledError class."""
return TimeSyncTool._get_exception_class()