Skip to content

Commit 6bb6f77

Browse files
committed
test(update): add comprehensive unit tests for update components
1 parent cb18c22 commit 6bb6f77

4 files changed

Lines changed: 1203 additions & 570 deletions

File tree

tests/test_executable_updater.py

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
"""Tests for the ExecutableUpdater component."""
2+
3+
import os
4+
import tempfile
5+
from pathlib import Path
6+
from unittest.mock import patch, Mock, MagicMock
7+
8+
import pytest
9+
import requests
10+
11+
from shello_cli.update.executable_updater import ExecutableUpdater
12+
from shello_cli.update.exceptions import DownloadError, UpdateError
13+
14+
15+
class TestExecutableUpdater:
16+
"""Test suite for ExecutableUpdater class."""
17+
18+
def test_init(self):
19+
"""Test ExecutableUpdater initialization."""
20+
updater = ExecutableUpdater("test-owner", "test-repo")
21+
22+
assert updater.repo_owner == "test-owner"
23+
assert updater.repo_name == "test-repo"
24+
assert updater.base_url == "https://github.com/test-owner/test-repo/releases/download"
25+
26+
def test_init_with_real_repo(self):
27+
"""Test initialization with real repository details."""
28+
updater = ExecutableUpdater("om-mapari", "shello-cli")
29+
30+
assert updater.repo_owner == "om-mapari"
31+
assert updater.repo_name == "shello-cli"
32+
assert "om-mapari" in updater.base_url
33+
assert "shello-cli" in updater.base_url
34+
35+
@patch("requests.get")
36+
def test_download_binary_success(self, mock_get):
37+
"""Test successful binary download."""
38+
# Setup mock response
39+
mock_response = Mock()
40+
mock_response.headers = {'content-length': '1024'}
41+
mock_response.iter_content = Mock(return_value=[b'test' * 256])
42+
mock_response.raise_for_status = Mock()
43+
mock_get.return_value = mock_response
44+
45+
updater = ExecutableUpdater("test-owner", "test-repo")
46+
47+
# Download binary
48+
result_path = updater.download_binary("v0.4.4", "shello.exe")
49+
50+
# Verify
51+
assert os.path.exists(result_path)
52+
assert "shello_update_shello.exe" in result_path
53+
54+
# Verify URL construction
55+
expected_url = "https://github.com/test-owner/test-repo/releases/download/v0.4.4/shello.exe"
56+
mock_get.assert_called_once()
57+
assert mock_get.call_args[0][0] == expected_url
58+
59+
# Cleanup
60+
if os.path.exists(result_path):
61+
os.remove(result_path)
62+
63+
@patch("requests.get")
64+
def test_download_binary_adds_v_prefix(self, mock_get):
65+
"""Test that download adds 'v' prefix to version if missing."""
66+
mock_response = Mock()
67+
mock_response.headers = {'content-length': '1024'}
68+
mock_response.iter_content = Mock(return_value=[b'test'])
69+
mock_response.raise_for_status = Mock()
70+
mock_get.return_value = mock_response
71+
72+
updater = ExecutableUpdater("test-owner", "test-repo")
73+
result_path = updater.download_binary("0.4.4", "shello.exe")
74+
75+
# Verify URL has 'v' prefix
76+
expected_url = "https://github.com/test-owner/test-repo/releases/download/v0.4.4/shello.exe"
77+
assert mock_get.call_args[0][0] == expected_url
78+
79+
# Cleanup
80+
if os.path.exists(result_path):
81+
os.remove(result_path)
82+
83+
@patch("requests.get")
84+
def test_download_binary_with_progress_callback(self, mock_get):
85+
"""Test download with progress callback."""
86+
mock_response = Mock()
87+
mock_response.headers = {'content-length': '2048'}
88+
mock_response.iter_content = Mock(return_value=[b'a' * 1024, b'b' * 1024])
89+
mock_response.raise_for_status = Mock()
90+
mock_get.return_value = mock_response
91+
92+
updater = ExecutableUpdater("test-owner", "test-repo")
93+
94+
# Track progress calls
95+
progress_calls = []
96+
def progress_callback(downloaded, total):
97+
progress_calls.append((downloaded, total))
98+
99+
result_path = updater.download_binary("v0.4.4", "shello", progress_callback)
100+
101+
# Verify progress was tracked
102+
assert len(progress_calls) == 2
103+
assert progress_calls[0] == (1024, 2048)
104+
assert progress_calls[1] == (2048, 2048)
105+
106+
# Cleanup
107+
if os.path.exists(result_path):
108+
os.remove(result_path)
109+
110+
@patch("requests.get")
111+
def test_download_binary_network_error(self, mock_get):
112+
"""Test download failure due to network error."""
113+
mock_get.side_effect = requests.RequestException("Network error")
114+
115+
updater = ExecutableUpdater("test-owner", "test-repo")
116+
117+
with pytest.raises(DownloadError) as exc_info:
118+
updater.download_binary("v0.4.4", "shello.exe")
119+
120+
assert "Failed to download binary" in str(exc_info.value)
121+
assert "Network error" in str(exc_info.value)
122+
123+
@patch("requests.get")
124+
def test_download_binary_http_error(self, mock_get):
125+
"""Test download failure due to HTTP error."""
126+
mock_response = Mock()
127+
mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found")
128+
mock_get.return_value = mock_response
129+
130+
updater = ExecutableUpdater("test-owner", "test-repo")
131+
132+
with pytest.raises(DownloadError) as exc_info:
133+
updater.download_binary("v0.4.4", "shello.exe")
134+
135+
assert "Failed to download binary" in str(exc_info.value)
136+
137+
def test_verify_binary_valid_file(self):
138+
"""Test binary verification with valid file."""
139+
updater = ExecutableUpdater("test-owner", "test-repo")
140+
141+
# Create temporary file with content
142+
with tempfile.NamedTemporaryFile(delete=False) as f:
143+
f.write(b"test binary content")
144+
temp_path = f.name
145+
146+
try:
147+
result = updater.verify_binary(temp_path)
148+
assert result is True
149+
finally:
150+
os.remove(temp_path)
151+
152+
def test_verify_binary_empty_file(self):
153+
"""Test binary verification with empty file."""
154+
updater = ExecutableUpdater("test-owner", "test-repo")
155+
156+
# Create empty temporary file
157+
with tempfile.NamedTemporaryFile(delete=False) as f:
158+
temp_path = f.name
159+
160+
try:
161+
result = updater.verify_binary(temp_path)
162+
assert result is False
163+
finally:
164+
os.remove(temp_path)
165+
166+
def test_verify_binary_nonexistent_file(self):
167+
"""Test binary verification with nonexistent file."""
168+
updater = ExecutableUpdater("test-owner", "test-repo")
169+
170+
result = updater.verify_binary("/nonexistent/path/to/file")
171+
assert result is False
172+
173+
def test_replace_executable_success(self):
174+
"""Test successful executable replacement."""
175+
updater = ExecutableUpdater("test-owner", "test-repo")
176+
177+
# Create temporary files
178+
with tempfile.NamedTemporaryFile(delete=False, mode='w') as old_file:
179+
old_file.write("old executable")
180+
old_path = old_file.name
181+
182+
with tempfile.NamedTemporaryFile(delete=False, mode='w') as new_file:
183+
new_file.write("new executable")
184+
new_path = new_file.name
185+
186+
backup_path = f"{old_path}.backup"
187+
188+
try:
189+
# Replace executable
190+
updater.replace_executable(new_path, old_path)
191+
192+
# Verify new content
193+
with open(old_path, 'r') as f:
194+
content = f.read()
195+
assert content == "new executable"
196+
197+
# Verify backup was removed
198+
assert not os.path.exists(backup_path)
199+
200+
# Verify new binary was moved (not copied)
201+
assert not os.path.exists(new_path)
202+
203+
finally:
204+
# Cleanup
205+
if os.path.exists(old_path):
206+
os.remove(old_path)
207+
if os.path.exists(new_path):
208+
os.remove(new_path)
209+
if os.path.exists(backup_path):
210+
os.remove(backup_path)
211+
212+
def test_replace_executable_creates_backup(self):
213+
"""Test that backup is created before replacement."""
214+
updater = ExecutableUpdater("test-owner", "test-repo")
215+
216+
# Create temporary files
217+
with tempfile.NamedTemporaryFile(delete=False, mode='w') as old_file:
218+
old_file.write("old executable")
219+
old_path = old_file.name
220+
221+
with tempfile.NamedTemporaryFile(delete=False, mode='w') as new_file:
222+
new_file.write("new executable")
223+
new_path = new_file.name
224+
225+
backup_path = f"{old_path}.backup"
226+
227+
# Patch shutil.copy2 to verify backup is created
228+
original_copy2 = __import__('shutil').copy2
229+
copy2_called = [False]
230+
231+
def mock_copy2(src, dst):
232+
copy2_called[0] = True
233+
return original_copy2(src, dst)
234+
235+
try:
236+
with patch('shutil.copy2', side_effect=mock_copy2):
237+
updater.replace_executable(new_path, old_path)
238+
239+
# Verify backup was created during the process
240+
assert copy2_called[0], "Backup creation (copy2) was not called"
241+
242+
# Verify final state: new content in place, backup removed
243+
with open(old_path, 'r') as f:
244+
content = f.read()
245+
assert content == "new executable"
246+
assert not os.path.exists(backup_path)
247+
248+
finally:
249+
# Cleanup
250+
if os.path.exists(old_path):
251+
os.remove(old_path)
252+
if os.path.exists(new_path):
253+
os.remove(new_path)
254+
if os.path.exists(backup_path):
255+
os.remove(backup_path)
256+
257+
def test_replace_executable_restores_on_failure(self):
258+
"""Test that backup is restored when replacement fails."""
259+
updater = ExecutableUpdater("test-owner", "test-repo")
260+
261+
# Create temporary files
262+
with tempfile.NamedTemporaryFile(delete=False, mode='w') as old_file:
263+
old_file.write("original content")
264+
old_path = old_file.name
265+
266+
with tempfile.NamedTemporaryFile(delete=False, mode='w') as new_file:
267+
new_file.write("new content")
268+
new_path = new_file.name
269+
270+
backup_path = f"{old_path}.backup"
271+
272+
try:
273+
# Patch shutil.move to fail during replacement
274+
with patch('shutil.move', side_effect=OSError("Simulated disk full")):
275+
with pytest.raises(UpdateError) as exc_info:
276+
updater.replace_executable(new_path, old_path)
277+
278+
assert "Failed to replace executable" in str(exc_info.value)
279+
280+
# Verify original file still exists (backup was removed after restore)
281+
assert os.path.exists(old_path)
282+
with open(old_path, 'r') as f:
283+
content = f.read()
284+
assert content == "original content"
285+
286+
# Verify backup was removed after restore
287+
assert not os.path.exists(backup_path)
288+
289+
finally:
290+
# Cleanup
291+
if os.path.exists(old_path):
292+
os.remove(old_path)
293+
if os.path.exists(new_path):
294+
os.remove(new_path)
295+
if os.path.exists(backup_path):
296+
os.remove(backup_path)
297+
298+
@patch('os.name', 'posix')
299+
@patch('os.chmod')
300+
def test_replace_executable_sets_permissions_unix(self, mock_chmod):
301+
"""Test that executable permissions are set on Unix systems."""
302+
updater = ExecutableUpdater("test-owner", "test-repo")
303+
304+
# Create temporary files
305+
with tempfile.NamedTemporaryFile(delete=False, mode='w') as old_file:
306+
old_file.write("old")
307+
old_path = old_file.name
308+
309+
with tempfile.NamedTemporaryFile(delete=False, mode='w') as new_file:
310+
new_file.write("new")
311+
new_path = new_file.name
312+
313+
try:
314+
updater.replace_executable(new_path, old_path)
315+
316+
# Verify chmod was called
317+
assert mock_chmod.called
318+
319+
finally:
320+
# Cleanup
321+
if os.path.exists(old_path):
322+
os.remove(old_path)
323+
if os.path.exists(new_path):
324+
os.remove(new_path)
325+
if os.path.exists(f"{old_path}.backup"):
326+
os.remove(f"{old_path}.backup")
327+
328+
def test_replace_executable_cleans_up_on_failure(self):
329+
"""Test that temporary files are cleaned up when replacement fails."""
330+
updater = ExecutableUpdater("test-owner", "test-repo")
331+
332+
# Create temporary files
333+
with tempfile.NamedTemporaryFile(delete=False, mode='w') as old_file:
334+
old_file.write("old")
335+
old_path = old_file.name
336+
337+
with tempfile.NamedTemporaryFile(delete=False, mode='w') as new_file:
338+
new_file.write("new")
339+
new_path = new_file.name
340+
341+
backup_path = f"{old_path}.backup"
342+
343+
try:
344+
# Force failure during replacement
345+
with patch('shutil.move', side_effect=OSError("Disk full")):
346+
with pytest.raises(UpdateError):
347+
updater.replace_executable(new_path, old_path)
348+
349+
# Verify cleanup: backup should be removed after restore
350+
assert not os.path.exists(backup_path)
351+
352+
# Original file should be restored
353+
assert os.path.exists(old_path)
354+
355+
finally:
356+
# Cleanup
357+
if os.path.exists(old_path):
358+
os.remove(old_path)
359+
if os.path.exists(new_path):
360+
os.remove(new_path)
361+
if os.path.exists(backup_path):
362+
os.remove(backup_path)

0 commit comments

Comments
 (0)