Skip to content

Commit e4db403

Browse files
committed
Tests: multipart upload chunk + WebDAV server start() branches
- _upload_chunk_with_progress: small/large path threshold, streaming generator yields 1MB pieces, timeout/headers/error propagation, exact-10MB boundary case. - WebDAVServer.start(): server_choice routing (auto/waitress/cheroot/ invalid), HTTPS-with-waitress fallback to HTTP, HTTPS-with-cheroot SSL adapter setup, missing-cert auto-generation, _create_wsgidav_app config shape. Coverage: 71% -> 72%; webdav_server: 47% -> 58%; drive.py: 76% -> 78%. Tests: 403 -> 419.
1 parent cb97bd4 commit e4db403

2 files changed

Lines changed: 308 additions & 0 deletions

File tree

tests/test_upload_chunk.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Tests for drive_service._upload_chunk_with_progress (PUT to pre-signed URL)."""
2+
from unittest.mock import MagicMock, patch
3+
4+
import pytest
5+
import requests
6+
7+
from services.drive import drive_service
8+
9+
10+
@pytest.fixture
11+
def mock_session_put():
12+
"""Patch requests.Session().put on a per-test basis."""
13+
fake_resp = MagicMock()
14+
fake_resp.status_code = 200
15+
fake_resp.raise_for_status.return_value = None
16+
17+
fake_session = MagicMock()
18+
fake_session.put.return_value = fake_resp
19+
20+
with patch.object(requests, 'Session', return_value=fake_session):
21+
yield fake_session
22+
23+
24+
# ---------- small chunk (direct PUT) ----------
25+
26+
def test_small_chunk_uploads_in_a_single_put(mock_session_put):
27+
drive_service._upload_chunk_with_progress("https://upload/", b"x" * 100, 60)
28+
mock_session_put.put.assert_called_once()
29+
args, kwargs = mock_session_put.put.call_args
30+
assert args[0] == "https://upload/"
31+
assert kwargs['data'] == b"x" * 100
32+
assert kwargs['timeout'] == 60
33+
assert kwargs['headers']['Content-Type'] == 'application/octet-stream'
34+
35+
36+
def test_small_chunk_raises_on_non_2xx(mock_session_put):
37+
err_resp = MagicMock()
38+
err_resp.raise_for_status.side_effect = requests.exceptions.HTTPError("500")
39+
mock_session_put.put.return_value = err_resp
40+
with pytest.raises(requests.exceptions.HTTPError):
41+
drive_service._upload_chunk_with_progress("https://u/", b"small", 60)
42+
43+
44+
# ---------- large chunk (>10MB, streamed in 1MB sub-chunks) ----------
45+
46+
def test_large_chunk_streams_data_via_generator(mock_session_put):
47+
"""Files >10MB stream a generator instead of sending all bytes at once."""
48+
big_data = b"X" * (15 * 1024 * 1024) # 15 MB
49+
drive_service._upload_chunk_with_progress("https://upload/", big_data, 300)
50+
51+
args, kwargs = mock_session_put.put.call_args
52+
# 'data' must be an iterator/generator, not bytes
53+
data_arg = kwargs['data']
54+
assert not isinstance(data_arg, bytes), "large chunk should stream, not send all bytes at once"
55+
# Iterating the generator must yield exactly the original bytes
56+
streamed = b"".join(data_arg)
57+
assert streamed == big_data
58+
59+
60+
def test_large_chunk_iterator_yields_1mb_pieces(mock_session_put):
61+
"""Streaming chunk size is 1MB so progress bar updates smoothly."""
62+
big_data = b"Z" * (12 * 1024 * 1024) # 12 MB
63+
64+
captured_pieces = []
65+
def fake_put(url, data=None, **kwargs):
66+
# Pull the generator and record piece sizes
67+
for piece in data:
68+
captured_pieces.append(len(piece))
69+
resp = MagicMock()
70+
resp.status_code = 200
71+
resp.raise_for_status.return_value = None
72+
return resp
73+
mock_session_put.put.side_effect = fake_put
74+
75+
drive_service._upload_chunk_with_progress("https://upload/", big_data, 300)
76+
77+
# Most pieces should be 1MB, the last may be smaller
78+
assert all(p <= 1024 * 1024 for p in captured_pieces)
79+
assert sum(captured_pieces) == len(big_data)
80+
81+
82+
def test_large_chunk_passes_correct_timeout(mock_session_put):
83+
big_data = b"Y" * (15 * 1024 * 1024)
84+
drive_service._upload_chunk_with_progress("https://upload/", big_data, 600)
85+
_, kwargs = mock_session_put.put.call_args
86+
assert kwargs['timeout'] == 600
87+
88+
89+
def test_large_chunk_propagates_http_error(mock_session_put):
90+
"""Generator path must also surface 5xx errors."""
91+
big_data = b"Q" * (11 * 1024 * 1024)
92+
err_resp = MagicMock()
93+
err_resp.raise_for_status.side_effect = requests.exceptions.HTTPError("503")
94+
95+
def fake_put(url, data=None, **kwargs):
96+
# Drain the generator (mimics what the requests library does)
97+
for _piece in data:
98+
pass
99+
return err_resp
100+
mock_session_put.put.side_effect = fake_put
101+
102+
with pytest.raises(requests.exceptions.HTTPError):
103+
drive_service._upload_chunk_with_progress("https://u/", big_data, 300)
104+
105+
106+
# ---------- threshold edge cases ----------
107+
108+
def test_chunk_exactly_at_10mb_uses_direct_put(mock_session_put):
109+
"""The threshold is 'len > 10MB' (strict greater-than) → exactly 10MB takes the small path."""
110+
data = b"a" * (10 * 1024 * 1024)
111+
drive_service._upload_chunk_with_progress("https://u/", data, 60)
112+
_, kwargs = mock_session_put.put.call_args
113+
# Direct path passes raw bytes
114+
assert kwargs['data'] == data
115+
116+
117+
def test_chunk_just_over_10mb_uses_streaming(mock_session_put):
118+
data = b"b" * (10 * 1024 * 1024 + 1)
119+
drive_service._upload_chunk_with_progress("https://u/", data, 60)
120+
_, kwargs = mock_session_put.put.call_args
121+
# Streaming path passes a generator
122+
assert not isinstance(kwargs['data'], bytes)
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""Tests for WebDAVServer.start() server-choice and SSL branches.
2+
3+
These cover the parts of start() that aren't a real listener — server
4+
selection (auto/waitress/cheroot/invalid), SSL fallback for waitress,
5+
SSL adapter setup for cheroot. The actual serve()/server.start() call is
6+
patched to short-circuit.
7+
"""
8+
from unittest.mock import MagicMock, patch
9+
10+
import pytest
11+
12+
from services.webdav_server import WebDAVServer
13+
14+
15+
@pytest.fixture
16+
def server():
17+
s = WebDAVServer()
18+
s._setup_signal_handlers = lambda: None
19+
return s
20+
21+
22+
def _start_with_mocks(server, *, server_choice='auto', protocol='http',
23+
host='127.0.0.1', port=8282,
24+
wsgi_server='waitress'):
25+
"""Build a fully-mocked start() invocation. Returns (result_dict, captured)."""
26+
import sys
27+
from config.config import config_service
28+
from services.webdav_provider import InternxtDAVProvider
29+
30+
captured = {'app_config': None, 'serve_kwargs': None,
31+
'cheroot_kwargs': None, 'cheroot_started': False}
32+
33+
class FakeApp:
34+
def __init__(self, cfg):
35+
captured['app_config'] = cfg
36+
37+
class FakeCherootServer:
38+
def __init__(self, **kw):
39+
captured['cheroot_kwargs'] = kw
40+
self.ssl_adapter = None
41+
def start(self):
42+
captured['cheroot_started'] = True
43+
raise RuntimeError("stop here") # short-circuit
44+
45+
fake_serve = MagicMock(side_effect=RuntimeError("stop here"))
46+
47+
# Inject fake cheroot.wsgi module
48+
fake_cheroot_wsgi = MagicMock()
49+
fake_cheroot_wsgi.Server = FakeCherootServer
50+
51+
fake_waitress = MagicMock()
52+
fake_waitress.serve = fake_serve
53+
54+
saved_modules = {}
55+
for mod_name in ('cheroot.wsgi', 'waitress'):
56+
saved_modules[mod_name] = sys.modules.get(mod_name)
57+
sys.modules['cheroot.wsgi'] = fake_cheroot_wsgi
58+
sys.modules['waitress'] = fake_waitress
59+
60+
cfg = {'host': host, 'port': port, 'protocol': protocol}
61+
try:
62+
with patch.object(config_service, 'read_webdav_config', return_value=cfg), \
63+
patch('wsgidav.wsgidav_app.WsgiDAVApp', FakeApp), \
64+
patch.object(InternxtDAVProvider, '__init__', return_value=None), \
65+
patch('services.webdav_server.WSGI_SERVER', wsgi_server):
66+
result = server.start(port=port, server_choice=server_choice)
67+
finally:
68+
for mod_name, prev in saved_modules.items():
69+
if prev is None:
70+
sys.modules.pop(mod_name, None)
71+
else:
72+
sys.modules[mod_name] = prev
73+
74+
captured['serve_kwargs'] = fake_serve.call_args[1] if fake_serve.called else None
75+
return result, captured
76+
77+
78+
# ---------- server choice routing ----------
79+
80+
def test_auto_falls_back_to_global_wsgi_server(server):
81+
"""server_choice='auto' uses whatever WSGI_SERVER was detected at import."""
82+
result, captured = _start_with_mocks(server, server_choice='auto',
83+
wsgi_server='waitress')
84+
# Reached the waitress serve() call (then short-circuited via RuntimeError)
85+
assert captured['serve_kwargs'] is not None
86+
assert captured['serve_kwargs']['host'] == '127.0.0.1'
87+
88+
89+
def test_explicit_waitress_choice_uses_waitress(server):
90+
result, captured = _start_with_mocks(server, server_choice='waitress',
91+
wsgi_server='cheroot') # global says cheroot
92+
# We forced waitress, so waitress.serve must be the path taken
93+
assert captured['serve_kwargs'] is not None
94+
95+
96+
def test_explicit_cheroot_choice_uses_cheroot(server):
97+
result, captured = _start_with_mocks(server, server_choice='cheroot',
98+
wsgi_server='waitress')
99+
# cheroot path → captured cheroot kwargs
100+
assert captured['cheroot_kwargs'] is not None
101+
assert captured['cheroot_started'] is True
102+
assert captured['cheroot_kwargs']['bind_addr'] == ('127.0.0.1', 8282)
103+
104+
105+
def test_invalid_server_choice_returns_failure(server):
106+
"""Anything other than auto/waitress/cheroot → ValueError caught,
107+
returned as success=False."""
108+
result, _ = _start_with_mocks(server, server_choice='nonexistent')
109+
assert result['success'] is False
110+
assert "Unknown server choice" in result['message']
111+
112+
113+
# ---------- SSL branches ----------
114+
115+
def test_https_with_waitress_falls_back_to_http(server):
116+
"""Waitress doesn't support SSL — must warn and fall back."""
117+
result, captured = _start_with_mocks(server, server_choice='waitress',
118+
protocol='https')
119+
# serve was called WITHOUT ssl_certificate (HTTP fallback)
120+
assert captured['serve_kwargs'] is not None
121+
assert 'ssl_certificate' not in captured['serve_kwargs']
122+
123+
124+
def test_https_with_cheroot_attempts_ssl_setup(server):
125+
"""Cheroot path must try to wire a BuiltinSSLAdapter."""
126+
import sys
127+
fake_adapter_class = MagicMock(return_value=MagicMock())
128+
fake_ssl_module = MagicMock()
129+
fake_ssl_module.BuiltinSSLAdapter = fake_adapter_class
130+
sys.modules['cheroot.ssl.builtin'] = fake_ssl_module
131+
132+
try:
133+
with patch('services.network_utils.NetworkUtils.WEBDAV_SSL_CERT_FILE') as cert_path, \
134+
patch('services.network_utils.NetworkUtils.WEBDAV_SSL_KEY_FILE') as key_path:
135+
cert_path.exists.return_value = True
136+
key_path.exists.return_value = True
137+
result, captured = _start_with_mocks(server, server_choice='cheroot',
138+
protocol='https')
139+
finally:
140+
sys.modules.pop('cheroot.ssl.builtin', None)
141+
142+
# Cheroot started AND SSL adapter was constructed
143+
assert captured['cheroot_kwargs'] is not None
144+
fake_adapter_class.assert_called_once()
145+
146+
147+
def test_https_with_cheroot_generates_certs_when_missing(server):
148+
"""If cert/key files are missing on disk, SSL setup must generate them."""
149+
import sys
150+
fake_ssl_module = MagicMock()
151+
fake_ssl_module.BuiltinSSLAdapter = MagicMock(return_value=MagicMock())
152+
sys.modules['cheroot.ssl.builtin'] = fake_ssl_module
153+
154+
try:
155+
with patch('services.network_utils.NetworkUtils.WEBDAV_SSL_CERT_FILE') as cert_path, \
156+
patch('services.network_utils.NetworkUtils.WEBDAV_SSL_KEY_FILE') as key_path, \
157+
patch('services.network_utils.NetworkUtils.generate_new_selfsigned_certs') as mock_gen:
158+
cert_path.exists.return_value = False
159+
key_path.exists.return_value = False
160+
_start_with_mocks(server, server_choice='cheroot', protocol='https')
161+
finally:
162+
sys.modules.pop('cheroot.ssl.builtin', None)
163+
164+
mock_gen.assert_called_once()
165+
166+
167+
# ---------- _create_wsgidav_app (the legacy helper, separate from start) ----------
168+
169+
def test_create_wsgidav_app_returns_app_instance(server):
170+
"""Verifies the app factory builds a WsgiDAVApp without error."""
171+
captured_config = {}
172+
173+
class FakeApp:
174+
def __init__(self, cfg):
175+
captured_config.update(cfg)
176+
177+
from services.webdav_provider import InternxtDAVProvider
178+
with patch('services.webdav_server.WsgiDAVApp', FakeApp), \
179+
patch.object(InternxtDAVProvider, '__init__', return_value=None):
180+
result = server._create_wsgidav_app()
181+
# Returned a FakeApp instance
182+
assert isinstance(result, FakeApp)
183+
# Critical config keys present
184+
assert 'provider_mapping' in captured_config
185+
assert '/' in captured_config['provider_mapping']
186+
assert captured_config['simple_dc']['user_mapping']['*']['internxt']['password'] == 'internxt-webdav'

0 commit comments

Comments
 (0)