Skip to content

Commit 0386129

Browse files
author
Mateusz
committed
Feat: test-system-time-mocking-fix; WIP; DO NOT REVERT!!!
1 parent aa93448 commit 0386129

File tree

3 files changed

+377
-2
lines changed

3 files changed

+377
-2
lines changed
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Time source service implementation.
2+
3+
This module provides the default TimeSource implementation that reads from
4+
the system clock, and a TimeOverride context manager for test-controlled time.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import asyncio
10+
import time
11+
from contextvars import ContextVar, Token
12+
from datetime import datetime, timezone
13+
from typing import Any
14+
15+
from src.core.interfaces.time_source_interface import ITimeSource
16+
17+
# ContextVar for storing override time source in async-safe way
18+
_OVERRIDE_TIME_SOURCE: ContextVar[ITimeSource | None] = ContextVar(
19+
"override_time_source", default=None
20+
)
21+
22+
23+
class TimeSource(ITimeSource):
24+
"""Default time source implementation using system clock.
25+
26+
When no override is active, this reads from the real system clock.
27+
When an override is active (via TimeOverride context manager), it uses
28+
the override time source instead.
29+
"""
30+
31+
def now_utc(self) -> datetime:
32+
"""Get the current UTC wall-clock time.
33+
34+
Returns:
35+
Current UTC datetime with timezone info
36+
"""
37+
override = _OVERRIDE_TIME_SOURCE.get()
38+
if override is not None:
39+
return override.now_utc()
40+
return datetime.now(timezone.utc)
41+
42+
def now_local(self) -> datetime:
43+
"""Get the current local wall-clock time.
44+
45+
Returns:
46+
Current local datetime with timezone info
47+
"""
48+
override = _OVERRIDE_TIME_SOURCE.get()
49+
if override is not None:
50+
return override.now_local()
51+
return datetime.now()
52+
53+
def unix_time_s(self) -> float:
54+
"""Get the current time as Unix epoch seconds.
55+
56+
This value is consistent with now_utc() - both use the same
57+
conceptual clock.
58+
59+
Returns:
60+
Seconds since Unix epoch (1970-01-01 00:00:00 UTC) as float
61+
"""
62+
override = _OVERRIDE_TIME_SOURCE.get()
63+
if override is not None:
64+
return override.unix_time_s()
65+
return time.time()
66+
67+
def monotonic_s(self) -> float:
68+
"""Get monotonic time (duration-only, not wall-clock).
69+
70+
This is suitable for measuring elapsed time but should not be used
71+
as a wall-clock timestamp for persisted or user-visible data.
72+
73+
Returns:
74+
Monotonic time in seconds as float
75+
"""
76+
override = _OVERRIDE_TIME_SOURCE.get()
77+
if override is not None:
78+
return override.monotonic_s()
79+
return time.monotonic()
80+
81+
async def sleep(self, seconds: float) -> None:
82+
"""Sleep for the specified duration.
83+
84+
Args:
85+
seconds: Duration to sleep in seconds
86+
"""
87+
override = _OVERRIDE_TIME_SOURCE.get()
88+
if override is not None:
89+
await override.sleep(seconds)
90+
else:
91+
await asyncio.sleep(seconds)
92+
93+
94+
class TimeOverride:
95+
"""Context manager for overriding time source in tests.
96+
97+
This provides an async-safe way to supply a deterministic time source
98+
for tests without global patching. The override is scoped to the
99+
context and does not leak to concurrent tests.
100+
101+
Usage:
102+
async with TimeOverride(mock_time_source):
103+
# All TimeSource calls use mock_time_source
104+
time_source = TimeSource()
105+
assert time_source.now_utc() == expected_time
106+
"""
107+
108+
def __init__(self, override_source: ITimeSource) -> None:
109+
"""Initialize the time override context.
110+
111+
Args:
112+
override_source: The time source to use within the context
113+
"""
114+
self._override_source = override_source
115+
self._token: Token[ITimeSource | None] | None = None
116+
117+
async def __aenter__(self) -> TimeOverride:
118+
"""Enter the override context."""
119+
self._token = _OVERRIDE_TIME_SOURCE.set(self._override_source)
120+
return self
121+
122+
async def __aexit__(
123+
self,
124+
exc_type: type[BaseException] | None,
125+
exc_val: BaseException | None,
126+
exc_tb: Any | None,
127+
) -> None:
128+
"""Exit the override context."""
129+
if self._token is not None:
130+
_OVERRIDE_TIME_SOURCE.reset(self._token)
131+
self._token = None
132+

tests/unit/core/interfaces/test_time_source_interface.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,8 @@
44

55
import asyncio
66
from datetime import datetime, timezone
7-
from typing import Any
87

98
import pytest
10-
119
from src.core.interfaces.time_source_interface import ITimeSource
1210

1311

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
"""Tests for TimeSource service implementation."""
2+
3+
from __future__ import annotations
4+
5+
import time
6+
from datetime import datetime, timezone
7+
8+
import pytest
9+
from src.core.interfaces.time_source_interface import ITimeSource
10+
from src.core.services.time_source_service import TimeOverride, TimeSource
11+
12+
13+
class TestTimeSourceDefaultBehavior:
14+
"""Test TimeSource default behavior (no override)."""
15+
16+
def test_now_utc_returns_current_utc_time(self) -> None:
17+
"""Test that now_utc returns current UTC time."""
18+
source = TimeSource()
19+
before = datetime.now(timezone.utc)
20+
result = source.now_utc()
21+
after = datetime.now(timezone.utc)
22+
23+
assert isinstance(result, datetime)
24+
assert result.tzinfo is not None
25+
assert before <= result <= after
26+
27+
def test_now_local_returns_current_local_time(self) -> None:
28+
"""Test that now_local returns current local time."""
29+
source = TimeSource()
30+
before = datetime.now()
31+
result = source.now_local()
32+
after = datetime.now()
33+
34+
assert isinstance(result, datetime)
35+
assert before <= result <= after
36+
37+
def test_unix_time_s_returns_current_epoch_seconds(self) -> None:
38+
"""Test that unix_time_s returns current epoch seconds."""
39+
source = TimeSource()
40+
before = time.time()
41+
result = source.unix_time_s()
42+
after = time.time()
43+
44+
assert isinstance(result, float)
45+
assert before <= result <= after
46+
47+
def test_unix_time_s_consistent_with_now_utc(self) -> None:
48+
"""Test that unix_time_s and now_utc are consistent."""
49+
source = TimeSource()
50+
unix_time = source.unix_time_s()
51+
utc_time = source.now_utc()
52+
53+
# Convert UTC datetime to Unix timestamp
54+
expected_unix = utc_time.timestamp()
55+
56+
# Allow small difference due to timing
57+
assert abs(unix_time - expected_unix) < 0.1
58+
59+
def test_monotonic_s_returns_monotonic_time(self) -> None:
60+
"""Test that monotonic_s returns monotonic time."""
61+
source = TimeSource()
62+
before = time.monotonic()
63+
result = source.monotonic_s()
64+
after = time.monotonic()
65+
66+
assert isinstance(result, float)
67+
assert before <= result <= after
68+
69+
@pytest.mark.asyncio
70+
async def test_sleep_delegates_to_asyncio_sleep(self) -> None:
71+
"""Test that sleep delegates to asyncio.sleep."""
72+
source = TimeSource()
73+
start = time.monotonic()
74+
await source.sleep(0.1)
75+
elapsed = time.monotonic() - start
76+
77+
# Should have slept approximately 0.1 seconds
78+
assert 0.05 <= elapsed < 0.5 # Allow some variance
79+
80+
def test_implements_itime_source_interface(self) -> None:
81+
"""Test that TimeSource implements ITimeSource interface."""
82+
source = TimeSource()
83+
assert isinstance(source, ITimeSource)
84+
85+
86+
class MockTimeSource(ITimeSource):
87+
"""Mock time source for testing TimeOverride."""
88+
89+
def __init__(
90+
self,
91+
utc_time: datetime,
92+
local_time: datetime,
93+
unix_time: float,
94+
monotonic_time: float,
95+
) -> None:
96+
"""Initialize mock time source."""
97+
self._utc_time = utc_time
98+
self._local_time = local_time
99+
self._unix_time = unix_time
100+
self._monotonic_time = monotonic_time
101+
self._sleep_calls: list[float] = []
102+
103+
def now_utc(self) -> datetime:
104+
"""Get mock UTC time."""
105+
return self._utc_time
106+
107+
def now_local(self) -> datetime:
108+
"""Get mock local time."""
109+
return self._local_time
110+
111+
def unix_time_s(self) -> float:
112+
"""Get mock Unix time."""
113+
return self._unix_time
114+
115+
def monotonic_s(self) -> float:
116+
"""Get mock monotonic time."""
117+
return self._monotonic_time
118+
119+
async def sleep(self, seconds: float) -> None:
120+
"""Record sleep call."""
121+
self._sleep_calls.append(seconds)
122+
await asyncio.sleep(0)
123+
124+
125+
class TestTimeOverride:
126+
"""Test TimeOverride context manager."""
127+
128+
@pytest.mark.asyncio
129+
async def test_override_active_within_context(self) -> None:
130+
"""Test that override is active within context."""
131+
fixed_utc = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
132+
fixed_local = datetime(2024, 1, 1, 12, 0, 0)
133+
fixed_unix = 1704110400.0
134+
fixed_monotonic = 1000.0
135+
136+
mock_source = MockTimeSource(
137+
utc_time=fixed_utc,
138+
local_time=fixed_local,
139+
unix_time=fixed_unix,
140+
monotonic_time=fixed_monotonic,
141+
)
142+
143+
source = TimeSource()
144+
145+
# Before override, should use system time
146+
before_override_utc = source.now_utc()
147+
assert before_override_utc != fixed_utc
148+
149+
# Within override context, should use mock
150+
async with TimeOverride(mock_source):
151+
assert source.now_utc() == fixed_utc
152+
assert source.now_local() == fixed_local
153+
assert source.unix_time_s() == fixed_unix
154+
assert source.monotonic_s() == fixed_monotonic
155+
156+
# After override, should use system time again
157+
after_override_utc = source.now_utc()
158+
assert after_override_utc != fixed_utc
159+
160+
@pytest.mark.asyncio
161+
async def test_override_sleep_delegates_to_mock(self) -> None:
162+
"""Test that sleep delegates to override source."""
163+
mock_source = MockTimeSource(
164+
utc_time=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
165+
local_time=datetime(2024, 1, 1, 12, 0, 0),
166+
unix_time=1704110400.0,
167+
monotonic_time=1000.0,
168+
)
169+
170+
source = TimeSource()
171+
172+
async with TimeOverride(mock_source):
173+
await source.sleep(1.5)
174+
175+
assert len(mock_source._sleep_calls) == 1
176+
assert mock_source._sleep_calls[0] == 1.5
177+
178+
@pytest.mark.asyncio
179+
async def test_override_does_not_leak_to_other_contexts(self) -> None:
180+
"""Test that override does not leak to concurrent contexts."""
181+
fixed_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
182+
mock_source = MockTimeSource(
183+
utc_time=fixed_time,
184+
local_time=datetime(2024, 1, 1, 12, 0, 0),
185+
unix_time=1704110400.0,
186+
monotonic_time=1000.0,
187+
)
188+
189+
source = TimeSource()
190+
191+
async def task_with_override() -> datetime:
192+
async with TimeOverride(mock_source):
193+
await asyncio.sleep(0.01) # Small delay
194+
return source.now_utc()
195+
196+
async def task_without_override() -> datetime:
197+
await asyncio.sleep(0.01) # Small delay
198+
return source.now_utc()
199+
200+
# Run tasks concurrently
201+
results = await asyncio.gather(
202+
task_with_override(), task_without_override()
203+
)
204+
205+
# Task with override should get fixed time
206+
assert results[0] == fixed_time
207+
208+
# Task without override should get system time (not fixed time)
209+
assert results[1] != fixed_time
210+
211+
@pytest.mark.asyncio
212+
async def test_nested_overrides(self) -> None:
213+
"""Test that nested overrides work correctly."""
214+
outer_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
215+
inner_time = datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc)
216+
217+
outer_mock = MockTimeSource(
218+
utc_time=outer_time,
219+
local_time=datetime(2024, 1, 1, 12, 0, 0),
220+
unix_time=1704110400.0,
221+
monotonic_time=1000.0,
222+
)
223+
224+
inner_mock = MockTimeSource(
225+
utc_time=inner_time,
226+
local_time=datetime(2024, 1, 1, 13, 0, 0),
227+
unix_time=1704114000.0,
228+
monotonic_time=2000.0,
229+
)
230+
231+
source = TimeSource()
232+
233+
async with TimeOverride(outer_mock):
234+
assert source.now_utc() == outer_time
235+
236+
async with TimeOverride(inner_mock):
237+
assert source.now_utc() == inner_time
238+
239+
# After inner override exits, should use outer again
240+
assert source.now_utc() == outer_time
241+
242+
# After outer override exits, should use system time
243+
assert source.now_utc() != outer_time
244+
assert source.now_utc() != inner_time
245+

0 commit comments

Comments
 (0)