File size: 19,791 Bytes
9024ad9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8ca285d
 
 
9024ad9
 
 
 
 
 
 
 
 
8ca285d
9024ad9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8ca285d
9024ad9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0497d92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8ca285d
0497d92
 
 
 
 
 
8ca285d
0497d92
 
 
 
 
 
 
 
 
 
 
 
 
8ca285d
0497d92
 
8ca285d
0497d92
 
 
 
8ca285d
0497d92
8ca285d
0497d92
 
 
 
 
 
8ca285d
0497d92
 
8ca285d
0497d92
 
 
 
 
 
 
 
 
 
 
 
 
 
8ca285d
 
0497d92
 
8ca285d
 
0497d92
8ca285d
 
0497d92
 
8ca285d
0497d92
 
8ca285d
 
 
 
 
 
6e01ea3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
"""
Tests for service layer.
"""
import pytest
from unittest.mock import patch, MagicMock
import httpx
from app.services.summarizer import OllamaService


class StubAsyncResponse:
    """A minimal stub of an httpx.Response-like object for testing."""

    def __init__(self, json_data=None, status_code=200, raise_for_status_exc=None):
        self._json_data = json_data or {}
        self.status_code = status_code
        self._raise_for_status_exc = raise_for_status_exc

    def json(self):
        return self._json_data

    def raise_for_status(self):
        if self._raise_for_status_exc is not None:
            raise self._raise_for_status_exc


class StubAsyncClient:
    """An async context manager stub that mimics httpx.AsyncClient for tests."""

    def __init__(self, post_result=None, post_exc=None, get_result=None, get_exc=None, *args, **kwargs):
        self._post_result = post_result
        self._post_exc = post_exc
        self._get_result = get_result
        self._get_exc = get_exc

    async def __aenter__(self):
        return self

    async def __aexit__(self, exc_type, exc, tb):
        return False

    async def post(self, *args, **kwargs):
        if self._post_exc is not None:
            raise self._post_exc
        return self._post_result or StubAsyncResponse()

    async def get(self, *args, **kwargs):
        if self._get_exc is not None:
            raise self._get_exc
        return self._get_result or StubAsyncResponse(status_code=200)


class TestOllamaService:
    """Test Ollama service."""
    
    @pytest.fixture
    def ollama_service(self):
        """Create Ollama service instance."""
        return OllamaService()
    
    def test_service_initialization(self, ollama_service):
        """Test service initialization."""
        assert ollama_service.base_url == "http://127.0.0.1:11434/"  # Has trailing slash
        assert ollama_service.model == "llama3.2:1b"  # Actual model name
        assert ollama_service.timeout == 30  # Test environment timeout
    
    @pytest.mark.asyncio
    async def test_summarize_text_success(self, ollama_service, mock_ollama_response):
        """Test successful text summarization."""
        stub_response = StubAsyncResponse(json_data=mock_ollama_response)
        with patch('httpx.AsyncClient', return_value=StubAsyncClient(post_result=stub_response)):
            result = await ollama_service.summarize_text("Test text")
            
            assert result["summary"] == mock_ollama_response["response"]
            assert result["model"] == "llama3.2:1b"  # Actual model name
            assert result["tokens_used"] == mock_ollama_response["eval_count"]
            assert "latency_ms" in result
    
    @pytest.mark.asyncio
    async def test_summarize_text_with_custom_params(self, ollama_service, mock_ollama_response):
        """Test summarization with custom parameters."""
        stub_response = StubAsyncResponse(json_data=mock_ollama_response)
        # Patch with a factory to capture payload for assertion
        captured = {}

        class CapturePostClient(StubAsyncClient):
            async def post(self, *args, **kwargs):
                captured['json'] = kwargs.get('json')
                return await super().post(*args, **kwargs)

        with patch('httpx.AsyncClient', return_value=CapturePostClient(post_result=stub_response)):
            result = await ollama_service.summarize_text(
                "Test text",
                max_tokens=512,
                prompt="Custom prompt"
            )

            assert result["summary"] == mock_ollama_response["response"]
            # Verify captured payload
            payload = captured['json']
            assert payload["options"]["num_predict"] == 512
            assert "Custom prompt" in payload["prompt"]
    
    @pytest.mark.asyncio
    async def test_summarize_text_timeout(self, ollama_service):
        """Test timeout handling."""
        with patch('httpx.AsyncClient', return_value=StubAsyncClient(post_exc=httpx.TimeoutException("Timeout"))):
            with pytest.raises(httpx.TimeoutException):
                await ollama_service.summarize_text("Test text")
    
    @pytest.mark.asyncio
    async def test_summarize_text_http_error(self, ollama_service):
        """Test HTTP error handling."""
        http_error = httpx.HTTPStatusError("Bad Request", request=MagicMock(), response=MagicMock())
        stub_response = StubAsyncResponse(raise_for_status_exc=http_error)
        with patch('httpx.AsyncClient', return_value=StubAsyncClient(post_result=stub_response)):
            with pytest.raises(httpx.HTTPError):
                await ollama_service.summarize_text("Test text")
    
    @pytest.mark.asyncio
    async def test_check_health_success(self, ollama_service):
        """Test successful health check."""
        stub_response = StubAsyncResponse(status_code=200)
        with patch('httpx.AsyncClient', return_value=StubAsyncClient(get_result=stub_response)):
            result = await ollama_service.check_health()
            assert result is True
    
    @pytest.mark.asyncio
    async def test_check_health_failure(self, ollama_service):
        """Test health check failure."""
        with patch('httpx.AsyncClient', return_value=StubAsyncClient(get_exc=httpx.HTTPError("Connection failed"))):
            result = await ollama_service.check_health()
            assert result is False

    # Tests for Dynamic Timeout System
    @pytest.mark.asyncio
    async def test_dynamic_timeout_small_text(self, ollama_service, mock_ollama_response):
        """Test dynamic timeout calculation for small text (should use base timeout)."""
        stub_response = StubAsyncResponse(json_data=mock_ollama_response)
        captured_timeout = None

        class TimeoutCaptureClient(StubAsyncClient):
            def __init__(self, *args, **kwargs):
                super().__init__(*args, **kwargs)
                self.timeout = None

            async def __aenter__(self):
                return self

            async def post(self, *args, **kwargs):
                return await super().post(*args, **kwargs)

        with patch('httpx.AsyncClient') as mock_client:
            mock_client.return_value = TimeoutCaptureClient(post_result=stub_response)
            mock_client.return_value.timeout = 30  # Test environment base timeout
            
            result = await ollama_service.summarize_text("Short text")
            
            # Verify the client was called with the base timeout
            mock_client.assert_called_once()
            call_args = mock_client.call_args
            assert call_args[1]['timeout'] == 30

    @pytest.mark.asyncio
    async def test_dynamic_timeout_large_text(self, ollama_service, mock_ollama_response):
        """Test dynamic timeout calculation for large text (should extend timeout)."""
        stub_response = StubAsyncResponse(json_data=mock_ollama_response)
        large_text = "A" * 5000  # 5000 characters
        
        with patch('httpx.AsyncClient') as mock_client:
            mock_client.return_value = StubAsyncClient(post_result=stub_response)
            
            result = await ollama_service.summarize_text(large_text)
            
            # Verify the client was called with extended timeout
            # Timeout calculated with ORIGINAL text length (5000 chars): 30 + (5000-1000)/1000 * 3 = 30 + 12 = 42s
            mock_client.assert_called_once()
            call_args = mock_client.call_args
            expected_timeout = 30 + (5000 - 1000) // 1000 * 3  # 42 seconds
            assert call_args[1]['timeout'] == expected_timeout

    @pytest.mark.asyncio
    async def test_dynamic_timeout_maximum_cap(self, ollama_service, mock_ollama_response):
        """Test that dynamic timeout is capped at 90 seconds."""
        stub_response = StubAsyncResponse(json_data=mock_ollama_response)
        very_large_text = "A" * 50000  # 50000 characters (should exceed 90s cap)
        
        with patch('httpx.AsyncClient') as mock_client:
            mock_client.return_value = StubAsyncClient(post_result=stub_response)
            
            result = await ollama_service.summarize_text(very_large_text)
            
            # Verify the timeout is capped at 90 seconds (actual cap)
            mock_client.assert_called_once()
            call_args = mock_client.call_args
            assert call_args[1]['timeout'] == 90  # Maximum cap

    @pytest.mark.asyncio
    async def test_dynamic_timeout_logging(self, ollama_service, mock_ollama_response, caplog):
        """Test that dynamic timeout calculation is logged correctly."""
        stub_response = StubAsyncResponse(json_data=mock_ollama_response)
        test_text = "A" * 2500  # 2500 characters
        
        with patch('httpx.AsyncClient', return_value=StubAsyncClient(post_result=stub_response)):
            await ollama_service.summarize_text(test_text)
            
            # Check that the logging message contains the correct information
            log_messages = [record.message for record in caplog.records]
            timeout_log = next((msg for msg in log_messages if "Processing text of" in msg), None)
            assert timeout_log is not None
            assert "2500 chars" in timeout_log
            assert "with timeout" in timeout_log

    @pytest.mark.asyncio
    async def test_timeout_error_message_improvement(self, ollama_service, caplog):
        """Test that timeout errors are logged with dynamic timeout and text length info."""
        test_text = "A" * 2000  # 2000 characters
        # Test environment sets OLLAMA_TIMEOUT=30, so: 30 + (2000-1000)//1000*3 = 30 + 3 = 33
        expected_timeout = 30 + (2000 - 1000) // 1000 * 3  # 33 seconds
        
        with patch('httpx.AsyncClient', return_value=StubAsyncClient(post_exc=httpx.TimeoutException("Timeout"))):
            with pytest.raises(httpx.TimeoutException):
                await ollama_service.summarize_text(test_text)
            
            # Verify the log message includes the dynamic timeout and text length
            log_messages = [record.message for record in caplog.records]
            timeout_log = next((msg for msg in log_messages if "Timeout calling Ollama after" in msg), None)
            assert timeout_log is not None
            assert f"after {expected_timeout}s" in timeout_log
            assert "chars=2000" in timeout_log

    # Tests for Streaming Functionality
    @pytest.mark.asyncio
    async def test_summarize_text_stream_success(self, ollama_service):
        """Test successful text streaming."""
        # Mock streaming response data
        mock_stream_data = [
            '{"response": "This", "done": false, "eval_count": 1}\n',
            '{"response": " is", "done": false, "eval_count": 2}\n',
            '{"response": " a", "done": false, "eval_count": 3}\n',
            '{"response": " test", "done": true, "eval_count": 4}\n'
        ]
        
        class MockStreamResponse:
            def __init__(self, data):
                self.data = data
                self._index = 0
            
            async def aiter_lines(self):
                for line in self.data:
                    yield line
            
            def raise_for_status(self):
                # Mock successful response
                pass
        
        mock_response = MockStreamResponse(mock_stream_data)
        
        class MockStreamContextManager:
            def __init__(self, response):
                self.response = response
            
            async def __aenter__(self):
                return self.response
            
            async def __aexit__(self, exc_type, exc, tb):
                return False
        
        class MockStreamClient:
            async def __aenter__(self):
                return self
            
            async def __aexit__(self, exc_type, exc, tb):
                return False
            
            def stream(self, method, url, **kwargs):
                # Return an async context manager
                return MockStreamContextManager(mock_response)
        
        with patch('httpx.AsyncClient', return_value=MockStreamClient()):
            chunks = []
            async for chunk in ollama_service.summarize_text_stream("Test text"):
                chunks.append(chunk)
            
            assert len(chunks) == 4
            assert chunks[0]["content"] == "This"
            assert chunks[0]["done"] is False
            assert chunks[0]["tokens_used"] == 1
            assert chunks[-1]["content"] == " test"
            assert chunks[-1]["done"] is True
            assert chunks[-1]["tokens_used"] == 4

    @pytest.mark.asyncio
    async def test_summarize_text_stream_with_custom_params(self, ollama_service):
        """Test streaming with custom parameters."""
        mock_stream_data = ['{"response": "Summary", "done": true, "eval_count": 1}\n']
        
        class MockStreamResponse:
            def __init__(self, data):
                self.data = data
            
            async def aiter_lines(self):
                for line in self.data:
                    yield line
            
            def raise_for_status(self):
                # Mock successful response
                pass
        
        mock_response = MockStreamResponse(mock_stream_data)
        captured_payload = {}
        
        class MockStreamContextManager:
            def __init__(self, response):
                self.response = response
            
            async def __aenter__(self):
                return self.response
            
            async def __aexit__(self, exc_type, exc, tb):
                return False
        
        class MockStreamClient:
            async def __aenter__(self):
                return self
            
            async def __aexit__(self, exc_type, exc, tb):
                return False
            
            def stream(self, method, url, **kwargs):
                captured_payload.update(kwargs.get('json', {}))
                return MockStreamContextManager(mock_response)
        
        with patch('httpx.AsyncClient', return_value=MockStreamClient()):
            chunks = []
            async for chunk in ollama_service.summarize_text_stream(
                "Test text",
                max_tokens=512,
                prompt="Custom prompt"
            ):
                chunks.append(chunk)
            
            # Verify captured payload
            assert captured_payload["stream"] is True
            assert captured_payload["options"]["num_predict"] == 512
            assert "Custom prompt" in captured_payload["prompt"]

    @pytest.mark.asyncio
    async def test_summarize_text_stream_timeout(self, ollama_service):
        """Test streaming timeout handling."""
        class MockStreamClient:
            async def __aenter__(self):
                return self
            
            async def __aexit__(self, exc_type, exc, tb):
                return False
            
            def stream(self, method, url, **kwargs):
                raise httpx.TimeoutException("Timeout")
        
        with patch('httpx.AsyncClient', return_value=MockStreamClient()):
            with pytest.raises(httpx.TimeoutException):
                chunks = []
                async for chunk in ollama_service.summarize_text_stream("Test text"):
                    chunks.append(chunk)

    @pytest.mark.asyncio
    async def test_summarize_text_stream_http_error(self, ollama_service):
        """Test streaming HTTP error handling."""
        http_error = httpx.HTTPStatusError("Bad Request", request=MagicMock(), response=MagicMock())
        
        class MockStreamClient:
            async def __aenter__(self):
                return self
            
            async def __aexit__(self, exc_type, exc, tb):
                return False
            
            def stream(self, method, url, **kwargs):
                raise http_error
        
        with patch('httpx.AsyncClient', return_value=MockStreamClient()):
            with pytest.raises(httpx.HTTPStatusError):
                chunks = []
                async for chunk in ollama_service.summarize_text_stream("Test text"):
                    chunks.append(chunk)

    @pytest.mark.asyncio
    async def test_summarize_text_stream_empty_response(self, ollama_service):
        """Test streaming with empty response."""
        mock_stream_data = []
        
        class MockStreamResponse:
            def __init__(self, data):
                self.data = data
            
            async def aiter_lines(self):
                for line in self.data:
                    yield line
            
            def raise_for_status(self):
                # Mock successful response
                pass
        
        mock_response = MockStreamResponse(mock_stream_data)
        
        class MockStreamContextManager:
            def __init__(self, response):
                self.response = response
            
            async def __aenter__(self):
                return self.response
            
            async def __aexit__(self, exc_type, exc, tb):
                return False
        
        class MockStreamClient:
            async def __aenter__(self):
                return self
            
            async def __aexit__(self, exc_type, exc, tb):
                return False
            
            def stream(self, method, url, **kwargs):
                return MockStreamContextManager(mock_response)
        
        with patch('httpx.AsyncClient', return_value=MockStreamClient()):
            chunks = []
            async for chunk in ollama_service.summarize_text_stream("Test text"):
                chunks.append(chunk)
            
            assert len(chunks) == 0

    @pytest.mark.asyncio
    async def test_summarize_text_stream_malformed_json(self, ollama_service):
        """Test streaming with malformed JSON response."""
        mock_stream_data = [
            '{"response": "Valid", "done": false, "eval_count": 1}\n',
            'invalid json line\n',
            '{"response": "End", "done": true, "eval_count": 2}\n'
        ]
        
        class MockStreamResponse:
            def __init__(self, data):
                self.data = data
            
            async def aiter_lines(self):
                for line in self.data:
                    yield line
            
            def raise_for_status(self):
                # Mock successful response
                pass
        
        mock_response = MockStreamResponse(mock_stream_data)
        
        class MockStreamContextManager:
            def __init__(self, response):
                self.response = response
            
            async def __aenter__(self):
                return self.response
            
            async def __aexit__(self, exc_type, exc, tb):
                return False
        
        class MockStreamClient:
            async def __aenter__(self):
                return self
            
            async def __aexit__(self, exc_type, exc, tb):
                return False
            
            def stream(self, method, url, **kwargs):
                return MockStreamContextManager(mock_response)
        
        with patch('httpx.AsyncClient', return_value=MockStreamClient()):
            chunks = []
            async for chunk in ollama_service.summarize_text_stream("Test text"):
                chunks.append(chunk)
            
            # Should skip malformed JSON and continue with valid chunks
            assert len(chunks) == 2
            assert chunks[0]["content"] == "Valid"
            assert chunks[1]["content"] == "End"