Skip to content

Commit 59777a4

Browse files
authored
Feat/add orchestrator unit tests (#55)
* feat: Add unit tests for RateLimiter * fix: Populate test file with code * fix: Relocate test file to correct directory * feat: Add unit tests for DownloadOrchestrator
1 parent c02ff6d commit 59777a4

File tree

2 files changed

+164
-27
lines changed

2 files changed

+164
-27
lines changed
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import pytest
2+
import asyncio
3+
import time
4+
from pathlib import Path
5+
from unittest.mock import MagicMock, AsyncMock, patch
6+
7+
# Make sure these import paths are correct for your project structure
8+
from forklet.core.orchestrator import DownloadOrchestrator
9+
from forklet.models import GitHubFile, DownloadStatus
10+
11+
# --- Test Fixtures for Setup ---
12+
13+
@pytest.fixture
14+
def mock_services():
15+
"""Creates mock objects for services used by the orchestrator."""
16+
github_service = MagicMock()
17+
download_service = MagicMock()
18+
github_service.get_repository_tree = AsyncMock()
19+
github_service.get_file_content = AsyncMock()
20+
download_service.save_content = AsyncMock(return_value=128)
21+
download_service.ensure_directory = AsyncMock()
22+
return github_service, download_service
23+
24+
@pytest.fixture
25+
def orchestrator(mock_services):
26+
"""Initializes the DownloadOrchestrator with mocked services."""
27+
github_service, download_service = mock_services
28+
orchestrator_instance = DownloadOrchestrator(
29+
github_service=github_service,
30+
download_service=download_service,
31+
max_concurrent_downloads=5
32+
)
33+
orchestrator_instance.reset_state()
34+
return orchestrator_instance
35+
36+
@pytest.fixture
37+
def mock_request():
38+
"""Creates a mock DownloadRequest object for use in tests."""
39+
request = MagicMock()
40+
request.repository.owner = "test-owner"
41+
request.repository.name = "test-repo"
42+
request.repository.display_name = "test-owner/test-repo"
43+
request.git_ref = "main"
44+
request.filters = MagicMock()
45+
request.filters.include_patterns = []
46+
request.filters.exclude_patterns = []
47+
request.destination = Path("/fake/destination")
48+
request.create_destination = True
49+
request.overwrite_existing = False
50+
request.preserve_structure = True
51+
request.show_progress_bars = False
52+
return request
53+
54+
# --- Test Cases ---
55+
56+
class TestDownloadOrchestrator:
57+
58+
def test_initialization_sets_properties_correctly(self, orchestrator):
59+
"""Verify that max_concurrent_downloads is correctly set."""
60+
assert orchestrator.max_concurrent_downloads == 5
61+
assert orchestrator._semaphore._value == 5
62+
assert not orchestrator._is_cancelled
63+
64+
@pytest.mark.asyncio
65+
async def test_execute_download_success(self, orchestrator, mock_services, mock_request):
66+
"""Simulate a successful download with mocked services."""
67+
github_service, _ = mock_services
68+
mock_file_list = [MagicMock(spec=GitHubFile, path="file1.txt", size=100)]
69+
github_service.get_repository_tree.return_value = mock_file_list
70+
71+
with patch.object(orchestrator, '_download_files_concurrently', new_callable=AsyncMock) as mock_downloader, \
72+
patch('forklet.core.orchestrator.FilterEngine') as mock_filter_engine:
73+
74+
mock_downloader.return_value = (["file1.txt"], {})
75+
mock_filter_engine.return_value.filter_files.return_value.included_files = mock_file_list
76+
77+
result = await orchestrator.execute_download(request=mock_request)
78+
79+
mock_downloader.assert_awaited_once()
80+
assert result.status == DownloadStatus.COMPLETED
81+
82+
@pytest.mark.asyncio
83+
async def test_execute_download_repo_fetch_fails(self, orchestrator, mock_services, mock_request):
84+
"""Test error handling when repository tree fetch fails."""
85+
github_service, _ = mock_services
86+
github_service.get_repository_tree.side_effect = Exception("API limit reached")
87+
88+
result = await orchestrator.execute_download(request=mock_request)
89+
90+
assert result.status == DownloadStatus.FAILED
91+
assert "API limit reached" in result.error_message
92+
93+
def test_cancel_sets_flag_and_logs(self, orchestrator):
94+
"""Test cancel() -> sets _is_cancelled=True and logs when a download is active."""
95+
orchestrator._current_result = MagicMock()
96+
97+
with patch('forklet.core.orchestrator.logger') as mock_logger:
98+
orchestrator.cancel()
99+
assert orchestrator._is_cancelled is True
100+
mock_logger.info.assert_called_with("Download cancelled by user")
101+
102+
@pytest.mark.asyncio
103+
async def test_pause_and_resume_flow(self, orchestrator, mock_services, mock_request):
104+
"""Tests the full pause and resume flow in a stable, controlled manner."""
105+
github_service, _ = mock_services
106+
# --- THIS IS THE FIX ---
107+
# The mock file MUST have a 'size' attribute for the sum() calculation.
108+
mock_file_list = [MagicMock(spec=GitHubFile, path="file1.txt", size=100)]
109+
# -----------------------
110+
github_service.get_repository_tree.return_value = mock_file_list
111+
112+
download_can_complete = asyncio.Event()
113+
114+
async def wait_for_signal_to_finish(*args, **kwargs):
115+
await download_can_complete.wait()
116+
return (["file1.txt"], {})
117+
118+
with patch.object(orchestrator, '_download_files_concurrently', side_effect=wait_for_signal_to_finish), \
119+
patch('forklet.core.orchestrator.FilterEngine') as mock_filter_engine:
120+
121+
mock_filter_engine.return_value.filter_files.return_value.included_files = mock_file_list
122+
123+
download_task = asyncio.create_task(orchestrator.execute_download(mock_request))
124+
125+
await asyncio.sleep(0.01)
126+
127+
if download_task.done() and download_task.exception():
128+
raise download_task.exception()
129+
130+
assert orchestrator._current_result is not None, "Orchestrator._current_result was not set."
131+
132+
await orchestrator.pause()
133+
assert orchestrator._is_paused is True
134+
assert orchestrator._current_result.status == DownloadStatus.PAUSED
135+
136+
await orchestrator.resume()
137+
assert orchestrator._is_paused is False
138+
assert orchestrator._current_result.status == DownloadStatus.IN_PROGRESS
139+
140+
download_can_complete.set()
141+
142+
final_result = await download_task
143+
assert final_result.status == DownloadStatus.COMPLETED
144+
145+
def test_get_current_progress_returns_none_when_inactive(self, orchestrator):
146+
"""Test get_current_progress() -> returns None when no download is active."""
147+
assert orchestrator.get_current_progress() is None

tests/infrastructure/test_rate_limiter.py

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
# Adjust this import path to match your project's structure
1010
from forklet.infrastructure.rate_limiter import RateLimiter, RateLimitInfo
1111

12-
# Mark all tests in this file as asyncio
13-
# pytestmark = pytest.mark.asyncio
1412

1513

1614
## 1. RateLimitInfo Helper Class Tests
@@ -33,20 +31,16 @@ def test_rate_limit_info_reset_in_seconds():
3331
"""Test the calculation of seconds until reset."""
3432
info = RateLimitInfo()
3533

36-
# Mock datetime.now() to control the current time for the test
3734
mock_now = datetime(2025, 10, 2, 12, 0, 0)
3835
with patch('forklet.infrastructure.rate_limiter.datetime', autospec=True) as mock_datetime:
3936
mock_datetime.now.return_value = mock_now
4037

41-
# Set reset_time 30 seconds into the future
4238
info.reset_time = mock_now + timedelta(seconds=30)
4339
assert info.reset_in_seconds == 30.0
4440

45-
# Set reset_time in the past
4641
info.reset_time = mock_now - timedelta(seconds=30)
4742
assert info.reset_in_seconds == 0.0, "Should not return negative time"
4843

49-
# No reset time set
5044
info.reset_time = None
5145
assert info.reset_in_seconds == 0.0
5246

@@ -86,7 +80,6 @@ async def test_update_rate_limit_info_sets_values_correctly():
8680
assert info.reset_time == datetime.fromtimestamp(reset_timestamp)
8781
assert not info.is_exhausted
8882

89-
9083
@pytest.mark.asyncio
9184
async def test_update_rate_limit_increments_consecutive_limits():
9285
"""Test that _consecutive_limits is handled correctly."""
@@ -108,51 +101,53 @@ async def test_update_rate_limit_increments_consecutive_limits():
108101
# ------------------------------------------
109102
@pytest.mark.asyncio
110103
@patch('asyncio.sleep', new_callable=AsyncMock)
104+
@pytest.mark.asyncio
111105
async def test_acquire_waits_when_primary_rate_limit_exhausted(mock_sleep):
112106
"""Test that acquire() waits for reset_in_seconds when exhausted."""
113107
rl = RateLimiter()
114108

115-
# Mock datetime.now() to control time
116109
mock_now = datetime(2025, 10, 2, 12, 0, 0)
117110
with patch('forklet.infrastructure.rate_limiter.datetime', autospec=True) as mock_datetime:
118111
mock_datetime.now.return_value = mock_now
119112

120-
# Set state to exhausted, with reset 15 seconds in the future
121113
rl.rate_limit_info.remaining = 5
122114
rl.rate_limit_info.reset_time = mock_now + timedelta(seconds=15)
123115

124116
await rl.acquire()
125117

126-
# Check that it slept for the primary rate limit duration
127118
mock_sleep.assert_any_call(15.0)
128119

129120
@pytest.mark.asyncio
130121
@patch('asyncio.sleep', new_callable=AsyncMock)
122+
@pytest.mark.asyncio
131123
async def test_acquire_uses_adaptive_delay(mock_sleep):
132-
"""Test that acquire() uses the calculated adaptive delay."""
124+
"""Test that acquire() uses the calculated adaptive delay on the second call."""
133125
rl = RateLimiter(default_delay=1.0)
134126

135-
# Mock time.time() for delay calculation
136-
with patch('time.time', side_effect=[1000.0, 1000.1]):
127+
# Mock time.time() to simulate time passing
128+
with patch('time.time', side_effect=[1000.0, 1000.1, 1000.2, 1000.3]) as mock_time:
137129
# Ensure rate limit is not exhausted
138-
rl.rate_limit_info.remaining = 2000
130+
rl.rate_limit_info.remaining = 2000
131+
132+
# FIRST call: This sets _last_request, but calculates a delay of 0.
133+
await rl.acquire()
134+
mock_sleep.assert_not_called() # No sleep on the first call
135+
assert rl._last_request == 1000.1
139136

137+
# SECOND call: This call is close to the first one, triggering the delay.
140138
await rl.acquire()
141139

142-
# Check that sleep was called. The exact value has jitter, so we check if it was called.
143-
# mock_sleep.assert_called()
144-
# The first call to time.time() is at the start of acquire(),
145-
# the second is for _last_request. The delay calculation uses the first one.
146-
# Expected delay is around 1.0 seconds.
147-
# assert mock_sleep.call_args[0][0] > 0.5
140+
# Assert that sleep was finally called on the second run
141+
mock_sleep.assert_called()
142+
# The delay should be > 0 because elapsed time (0.1s) < default_delay (1.0s)
143+
assert mock_sleep.call_args[0][0] > 0
148144

149145
@pytest.mark.asyncio
150146
async def test_acquire_updates_last_request_time():
151147
"""Test that acquire() correctly updates the _last_request timestamp."""
152148
rl = RateLimiter()
153149

154-
with patch('time.time', return_value=12345.0):
155-
# Patch sleep to make the test run instantly
150+
with patch('time.time', return_value=12345.0) as mock_time:
156151
with patch('asyncio.sleep'):
157152
await rl.acquire()
158153
assert rl._last_request == 12345.0
@@ -167,11 +162,9 @@ async def test_update_rate_limit_info_is_task_safe():
167162
num_tasks = 50
168163

169164
async def worker(headers):
170-
# Add a small, random delay to increase the chance of race conditions if unlocked
171165
await asyncio.sleep(0.01 * random.random())
172166
await rl.update_rate_limit_info(headers)
173167

174-
# Create many concurrent tasks
175168
all_headers = []
176169
for i in range(num_tasks):
177170
headers = {
@@ -183,12 +176,9 @@ async def worker(headers):
183176
tasks = [asyncio.create_task(worker(h)) for h in all_headers]
184177
await asyncio.gather(*tasks)
185178

186-
# The final state should be internally consistent, belonging to one of the updates.
187-
# If limit is 5000+i, remaining must be 4000+i.
188179
final_limit = rl.rate_limit_info.limit
189180
final_remaining = rl.rate_limit_info.remaining
190181

191-
# Calculate what 'i' must have been based on the final limit
192182
i = final_limit - 5000
193183
expected_remaining = 4000 + i
194184
assert final_remaining == expected_remaining, "Inconsistent state suggests a race condition"

0 commit comments

Comments
 (0)