Michael Hu commited on
Commit
acd758a
·
1 Parent(s): 48f8a08

Create unit tests for application layer

Browse files
tests/unit/application/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Unit tests for application layer"""
tests/unit/application/dtos/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Unit tests for application DTOs"""
tests/unit/application/dtos/test_audio_upload_dto.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for AudioUploadDto"""
2
+
3
+ import pytest
4
+ import os
5
+
6
+ from src.application.dtos.audio_upload_dto import AudioUploadDto
7
+
8
+
9
+ class TestAudioUploadDto:
10
+ """Test cases for AudioUploadDto"""
11
+
12
+ def test_valid_audio_upload_dto(self):
13
+ """Test creating a valid AudioUploadDto"""
14
+ filename = "test_audio.wav"
15
+ content = b"fake_audio_content_" + b"x" * 1000 # 1KB+ of fake audio
16
+ content_type = "audio/wav"
17
+
18
+ dto = AudioUploadDto(
19
+ filename=filename,
20
+ content=content,
21
+ content_type=content_type
22
+ )
23
+
24
+ assert dto.filename == filename
25
+ assert dto.content == content
26
+ assert dto.content_type == content_type
27
+ assert dto.size == len(content)
28
+
29
+ def test_audio_upload_dto_with_explicit_size(self):
30
+ """Test creating AudioUploadDto with explicit size"""
31
+ filename = "test_audio.mp3"
32
+ content = b"fake_audio_content_" + b"x" * 2000
33
+ content_type = "audio/mpeg"
34
+ size = 2500
35
+
36
+ dto = AudioUploadDto(
37
+ filename=filename,
38
+ content=content,
39
+ content_type=content_type,
40
+ size=size
41
+ )
42
+
43
+ assert dto.size == size # Should use explicit size, not calculated
44
+
45
+ def test_empty_filename_validation(self):
46
+ """Test validation with empty filename"""
47
+ with pytest.raises(ValueError, match="Filename cannot be empty"):
48
+ AudioUploadDto(
49
+ filename="",
50
+ content=b"fake_audio_content_" + b"x" * 1000,
51
+ content_type="audio/wav"
52
+ )
53
+
54
+ def test_empty_content_validation(self):
55
+ """Test validation with empty content"""
56
+ with pytest.raises(ValueError, match="Audio content cannot be empty"):
57
+ AudioUploadDto(
58
+ filename="test.wav",
59
+ content=b"",
60
+ content_type="audio/wav"
61
+ )
62
+
63
+ def test_empty_content_type_validation(self):
64
+ """Test validation with empty content type"""
65
+ with pytest.raises(ValueError, match="Content type cannot be empty"):
66
+ AudioUploadDto(
67
+ filename="test.wav",
68
+ content=b"fake_audio_content_" + b"x" * 1000,
69
+ content_type=""
70
+ )
71
+
72
+ def test_unsupported_file_extension_validation(self):
73
+ """Test validation with unsupported file extension"""
74
+ with pytest.raises(ValueError, match="Unsupported file extension"):
75
+ AudioUploadDto(
76
+ filename="test.xyz",
77
+ content=b"fake_audio_content_" + b"x" * 1000,
78
+ content_type="audio/wav"
79
+ )
80
+
81
+ def test_supported_file_extensions(self):
82
+ """Test all supported file extensions"""
83
+ supported_extensions = ['.wav', '.mp3', '.m4a', '.flac', '.ogg']
84
+ content = b"fake_audio_content_" + b"x" * 1000
85
+
86
+ for ext in supported_extensions:
87
+ filename = f"test{ext}"
88
+ content_type = f"audio/{ext[1:]}" if ext != '.m4a' else "audio/mp4"
89
+
90
+ # Should not raise exception
91
+ dto = AudioUploadDto(
92
+ filename=filename,
93
+ content=content,
94
+ content_type=content_type
95
+ )
96
+ assert dto.file_extension == ext
97
+
98
+ def test_case_insensitive_extension_validation(self):
99
+ """Test case insensitive extension validation"""
100
+ content = b"fake_audio_content_" + b"x" * 1000
101
+
102
+ # Should work with uppercase extension
103
+ dto = AudioUploadDto(
104
+ filename="test.WAV",
105
+ content=content,
106
+ content_type="audio/wav"
107
+ )
108
+ assert dto.file_extension == ".wav" # Should be normalized to lowercase
109
+
110
+ def test_file_too_large_validation(self):
111
+ """Test validation with file too large"""
112
+ max_size = 100 * 1024 * 1024 # 100MB
113
+ large_content = b"x" * (max_size + 1)
114
+
115
+ with pytest.raises(ValueError, match="File too large"):
116
+ AudioUploadDto(
117
+ filename="test.wav",
118
+ content=large_content,
119
+ content_type="audio/wav"
120
+ )
121
+
122
+ def test_file_too_small_validation(self):
123
+ """Test validation with file too small"""
124
+ small_content = b"x" * 500 # Less than 1KB
125
+
126
+ with pytest.raises(ValueError, match="File too small"):
127
+ AudioUploadDto(
128
+ filename="test.wav",
129
+ content=small_content,
130
+ content_type="audio/wav"
131
+ )
132
+
133
+ def test_invalid_content_type_validation(self):
134
+ """Test validation with invalid content type"""
135
+ content = b"fake_audio_content_" + b"x" * 1000
136
+
137
+ with pytest.raises(ValueError, match="Invalid content type"):
138
+ AudioUploadDto(
139
+ filename="test.wav",
140
+ content=content,
141
+ content_type="text/plain" # Not audio/*
142
+ )
143
+
144
+ def test_file_extension_property(self):
145
+ """Test file_extension property"""
146
+ dto = AudioUploadDto(
147
+ filename="test_audio.MP3",
148
+ content=b"fake_audio_content_" + b"x" * 1000,
149
+ content_type="audio/mpeg"
150
+ )
151
+
152
+ assert dto.file_extension == ".mp3"
153
+
154
+ def test_base_filename_property(self):
155
+ """Test base_filename property"""
156
+ dto = AudioUploadDto(
157
+ filename="test_audio.wav",
158
+ content=b"fake_audio_content_" + b"x" * 1000,
159
+ content_type="audio/wav"
160
+ )
161
+
162
+ assert dto.base_filename == "test_audio"
163
+
164
+ def test_to_dict_method(self):
165
+ """Test to_dict method"""
166
+ filename = "test_audio.wav"
167
+ content = b"fake_audio_content_" + b"x" * 1000
168
+ content_type = "audio/wav"
169
+
170
+ dto = AudioUploadDto(
171
+ filename=filename,
172
+ content=content,
173
+ content_type=content_type
174
+ )
175
+
176
+ result = dto.to_dict()
177
+
178
+ assert result['filename'] == filename
179
+ assert result['content_type'] == content_type
180
+ assert result['size'] == len(content)
181
+ assert result['file_extension'] == ".wav"
182
+
183
+ # Should not include content in dict representation
184
+ assert 'content' not in result
185
+
186
+ def test_size_calculation_on_init(self):
187
+ """Test that size is calculated automatically if not provided"""
188
+ content = b"fake_audio_content_" + b"x" * 1500
189
+
190
+ dto = AudioUploadDto(
191
+ filename="test.wav",
192
+ content=content,
193
+ content_type="audio/wav"
194
+ )
195
+
196
+ assert dto.size == len(content)
197
+
198
+ def test_validation_called_on_init(self):
199
+ """Test that validation is called during initialization"""
200
+ # This should trigger validation and raise an error
201
+ with pytest.raises(ValueError):
202
+ AudioUploadDto(
203
+ filename="", # Invalid empty filename
204
+ content=b"fake_audio_content_" + b"x" * 1000,
205
+ content_type="audio/wav"
206
+ )
207
+
208
+ def test_edge_case_minimum_valid_size(self):
209
+ """Test edge case with minimum valid file size"""
210
+ min_content = b"x" * 1024 # Exactly 1KB
211
+
212
+ dto = AudioUploadDto(
213
+ filename="test.wav",
214
+ content=min_content,
215
+ content_type="audio/wav"
216
+ )
217
+
218
+ assert dto.size == 1024
219
+
220
+ def test_edge_case_maximum_valid_size(self):
221
+ """Test edge case with maximum valid file size"""
222
+ max_size = 100 * 1024 * 1024 # Exactly 100MB
223
+ max_content = b"x" * max_size
224
+
225
+ dto = AudioUploadDto(
226
+ filename="test.wav",
227
+ content=max_content,
228
+ content_type="audio/wav"
229
+ )
230
+
231
+ assert dto.size == max_size
232
+
233
+ def test_content_type_mismatch_handling(self):
234
+ """Test handling of content type mismatch with filename"""
235
+ content = b"fake_audio_content_" + b"x" * 1000
236
+
237
+ # This should still work as long as content_type starts with 'audio/'
238
+ dto = AudioUploadDto(
239
+ filename="test.wav",
240
+ content=content,
241
+ content_type="audio/mpeg" # Different from .wav extension
242
+ )
243
+
244
+ assert dto.content_type == "audio/mpeg"
245
+ assert dto.file_extension == ".wav"
tests/unit/application/dtos/test_dto_validation.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for DTO validation utilities"""
2
+
3
+ import pytest
4
+ from unittest.mock import Mock
5
+
6
+ from src.application.dtos.dto_validation import (
7
+ ValidationError,
8
+ validate_dto,
9
+ validation_required,
10
+ validate_field,
11
+ validate_required,
12
+ validate_type,
13
+ validate_range,
14
+ validate_choices
15
+ )
16
+
17
+
18
+ class TestValidationError:
19
+ """Test cases for ValidationError"""
20
+
21
+ def test_validation_error_basic(self):
22
+ """Test basic ValidationError creation"""
23
+ error = ValidationError("Test error message")
24
+
25
+ assert str(error) == "Validation error: Test error message"
26
+ assert error.message == "Test error message"
27
+ assert error.field is None
28
+ assert error.value is None
29
+
30
+ def test_validation_error_with_field(self):
31
+ """Test ValidationError with field information"""
32
+ error = ValidationError("Invalid value", field="test_field", value="invalid_value")
33
+
34
+ assert str(error) == "Validation error for field 'test_field': Invalid value"
35
+ assert error.message == "Invalid value"
36
+ assert error.field == "test_field"
37
+ assert error.value == "invalid_value"
38
+
39
+ def test_validation_error_without_field_but_with_value(self):
40
+ """Test ValidationError without field but with value"""
41
+ error = ValidationError("Invalid value", value="test_value")
42
+
43
+ assert str(error) == "Validation error: Invalid value"
44
+ assert error.message == "Invalid value"
45
+ assert error.field is None
46
+ assert error.value == "test_value"
47
+
48
+
49
+ class TestValidateDto:
50
+ """Test cases for validate_dto function"""
51
+
52
+ def test_validate_dto_success_with_validate_method(self):
53
+ """Test successful DTO validation with _validate method"""
54
+ mock_dto = Mock()
55
+ mock_dto._validate = Mock()
56
+
57
+ result = validate_dto(mock_dto)
58
+
59
+ assert result is True
60
+ mock_dto._validate.assert_called_once()
61
+
62
+ def test_validate_dto_success_without_validate_method(self):
63
+ """Test successful DTO validation without _validate method"""
64
+ mock_dto = Mock()
65
+ del mock_dto._validate # Remove the _validate method
66
+
67
+ result = validate_dto(mock_dto)
68
+
69
+ assert result is True
70
+
71
+ def test_validate_dto_failure_value_error(self):
72
+ """Test DTO validation failure with ValueError"""
73
+ mock_dto = Mock()
74
+ mock_dto._validate.side_effect = ValueError("Validation failed")
75
+
76
+ with pytest.raises(ValidationError, match="Validation failed"):
77
+ validate_dto(mock_dto)
78
+
79
+ def test_validate_dto_failure_unexpected_error(self):
80
+ """Test DTO validation failure with unexpected error"""
81
+ mock_dto = Mock()
82
+ mock_dto._validate.side_effect = RuntimeError("Unexpected error")
83
+
84
+ with pytest.raises(ValidationError, match="Validation failed: Unexpected error"):
85
+ validate_dto(mock_dto)
86
+
87
+
88
+ class TestValidationRequired:
89
+ """Test cases for validation_required decorator"""
90
+
91
+ def test_validation_required_success(self):
92
+ """Test validation_required decorator with successful validation"""
93
+ mock_instance = Mock()
94
+ mock_instance._validate = Mock()
95
+
96
+ @validation_required
97
+ def test_method(self):
98
+ return "success"
99
+
100
+ result = test_method(mock_instance)
101
+
102
+ assert result == "success"
103
+ mock_instance._validate.assert_called_once()
104
+
105
+ def test_validation_required_validation_error(self):
106
+ """Test validation_required decorator with validation error"""
107
+ mock_instance = Mock()
108
+ mock_instance._validate.side_effect = ValueError("Validation failed")
109
+
110
+ @validation_required
111
+ def test_method(self):
112
+ return "success"
113
+
114
+ with pytest.raises(ValidationError, match="Validation failed"):
115
+ test_method(mock_instance)
116
+
117
+ def test_validation_required_method_error(self):
118
+ """Test validation_required decorator with method execution error"""
119
+ mock_instance = Mock()
120
+ mock_instance._validate = Mock()
121
+
122
+ @validation_required
123
+ def test_method(self):
124
+ raise RuntimeError("Method error")
125
+
126
+ with pytest.raises(ValidationError, match="Error in test_method: Method error"):
127
+ test_method(mock_instance)
128
+
129
+ def test_validation_required_preserves_function_metadata(self):
130
+ """Test that validation_required preserves function metadata"""
131
+ @validation_required
132
+ def test_method(self):
133
+ """Test method docstring"""
134
+ return "success"
135
+
136
+ assert test_method.__name__ == "test_method"
137
+ assert test_method.__doc__ == "Test method docstring"
138
+
139
+
140
+ class TestValidateField:
141
+ """Test cases for validate_field function"""
142
+
143
+ def test_validate_field_success(self):
144
+ """Test successful field validation"""
145
+ def is_positive(value):
146
+ return value > 0
147
+
148
+ result = validate_field(5, "test_field", is_positive)
149
+
150
+ assert result == 5
151
+
152
+ def test_validate_field_failure_with_custom_message(self):
153
+ """Test field validation failure with custom error message"""
154
+ def is_positive(value):
155
+ return value > 0
156
+
157
+ with pytest.raises(ValidationError, match="Custom error message"):
158
+ validate_field(-1, "test_field", is_positive, "Custom error message")
159
+
160
+ def test_validate_field_failure_with_default_message(self):
161
+ """Test field validation failure with default error message"""
162
+ def is_positive(value):
163
+ return value > 0
164
+
165
+ with pytest.raises(ValidationError, match="Invalid value for field 'test_field'"):
166
+ validate_field(-1, "test_field", is_positive)
167
+
168
+ def test_validate_field_validator_exception(self):
169
+ """Test field validation with validator raising exception"""
170
+ def failing_validator(value):
171
+ raise RuntimeError("Validator error")
172
+
173
+ with pytest.raises(ValidationError, match="Validation error for field 'test_field': Validator error"):
174
+ validate_field(5, "test_field", failing_validator)
175
+
176
+
177
+ class TestValidateRequired:
178
+ """Test cases for validate_required function"""
179
+
180
+ def test_validate_required_success_with_value(self):
181
+ """Test successful required validation with valid value"""
182
+ result = validate_required("test_value", "test_field")
183
+ assert result == "test_value"
184
+
185
+ def test_validate_required_success_with_zero(self):
186
+ """Test successful required validation with zero (falsy but valid)"""
187
+ result = validate_required(0, "test_field")
188
+ assert result == 0
189
+
190
+ def test_validate_required_success_with_false(self):
191
+ """Test successful required validation with False (falsy but valid)"""
192
+ result = validate_required(False, "test_field")
193
+ assert result is False
194
+
195
+ def test_validate_required_failure_with_none(self):
196
+ """Test required validation failure with None"""
197
+ with pytest.raises(ValidationError, match="Field 'test_field' is required"):
198
+ validate_required(None, "test_field")
199
+
200
+ def test_validate_required_failure_with_empty_string(self):
201
+ """Test required validation failure with empty string"""
202
+ with pytest.raises(ValidationError, match="Field 'test_field' cannot be empty"):
203
+ validate_required("", "test_field")
204
+
205
+ def test_validate_required_failure_with_empty_list(self):
206
+ """Test required validation failure with empty list"""
207
+ with pytest.raises(ValidationError, match="Field 'test_field' cannot be empty"):
208
+ validate_required([], "test_field")
209
+
210
+ def test_validate_required_failure_with_empty_dict(self):
211
+ """Test required validation failure with empty dict"""
212
+ with pytest.raises(ValidationError, match="Field 'test_field' cannot be empty"):
213
+ validate_required({}, "test_field")
214
+
215
+
216
+ class TestValidateType:
217
+ """Test cases for validate_type function"""
218
+
219
+ def test_validate_type_success_single_type(self):
220
+ """Test successful type validation with single type"""
221
+ result = validate_type("test", "test_field", str)
222
+ assert result == "test"
223
+
224
+ def test_validate_type_success_multiple_types(self):
225
+ """Test successful type validation with multiple types"""
226
+ result1 = validate_type("test", "test_field", (str, int))
227
+ assert result1 == "test"
228
+
229
+ result2 = validate_type(123, "test_field", (str, int))
230
+ assert result2 == 123
231
+
232
+ def test_validate_type_failure_single_type(self):
233
+ """Test type validation failure with single type"""
234
+ with pytest.raises(ValidationError, match="Field 'test_field' must be of type str, got int"):
235
+ validate_type(123, "test_field", str)
236
+
237
+ def test_validate_type_failure_multiple_types(self):
238
+ """Test type validation failure with multiple types"""
239
+ with pytest.raises(ValidationError, match="Field 'test_field' must be of type str or int, got float"):
240
+ validate_type(1.5, "test_field", (str, int))
241
+
242
+
243
+ class TestValidateRange:
244
+ """Test cases for validate_range function"""
245
+
246
+ def test_validate_range_success_within_range(self):
247
+ """Test successful range validation within range"""
248
+ result = validate_range(5, "test_field", min_value=1, max_value=10)
249
+ assert result == 5
250
+
251
+ def test_validate_range_success_at_boundaries(self):
252
+ """Test successful range validation at boundaries"""
253
+ result1 = validate_range(1, "test_field", min_value=1, max_value=10)
254
+ assert result1 == 1
255
+
256
+ result2 = validate_range(10, "test_field", min_value=1, max_value=10)
257
+ assert result2 == 10
258
+
259
+ def test_validate_range_success_only_min(self):
260
+ """Test successful range validation with only minimum"""
261
+ result = validate_range(5, "test_field", min_value=1)
262
+ assert result == 5
263
+
264
+ def test_validate_range_success_only_max(self):
265
+ """Test successful range validation with only maximum"""
266
+ result = validate_range(5, "test_field", max_value=10)
267
+ assert result == 5
268
+
269
+ def test_validate_range_success_no_limits(self):
270
+ """Test successful range validation with no limits"""
271
+ result = validate_range(5, "test_field")
272
+ assert result == 5
273
+
274
+ def test_validate_range_failure_below_minimum(self):
275
+ """Test range validation failure below minimum"""
276
+ with pytest.raises(ValidationError, match="Field 'test_field' must be >= 1, got 0"):
277
+ validate_range(0, "test_field", min_value=1, max_value=10)
278
+
279
+ def test_validate_range_failure_above_maximum(self):
280
+ """Test range validation failure above maximum"""
281
+ with pytest.raises(ValidationError, match="Field 'test_field' must be <= 10, got 11"):
282
+ validate_range(11, "test_field", min_value=1, max_value=10)
283
+
284
+ def test_validate_range_with_float_values(self):
285
+ """Test range validation with float values"""
286
+ result = validate_range(1.5, "test_field", min_value=1.0, max_value=2.0)
287
+ assert result == 1.5
288
+
289
+
290
+ class TestValidateChoices:
291
+ """Test cases for validate_choices function"""
292
+
293
+ def test_validate_choices_success(self):
294
+ """Test successful choices validation"""
295
+ choices = ["option1", "option2", "option3"]
296
+ result = validate_choices("option2", "test_field", choices)
297
+ assert result == "option2"
298
+
299
+ def test_validate_choices_failure(self):
300
+ """Test choices validation failure"""
301
+ choices = ["option1", "option2", "option3"]
302
+
303
+ with pytest.raises(ValidationError, match="Field 'test_field' must be one of \\['option1', 'option2', 'option3'\\], got 'invalid'"):
304
+ validate_choices("invalid", "test_field", choices)
305
+
306
+ def test_validate_choices_with_different_types(self):
307
+ """Test choices validation with different value types"""
308
+ choices = [1, 2, 3, "four"]
309
+
310
+ result1 = validate_choices(2, "test_field", choices)
311
+ assert result1 == 2
312
+
313
+ result2 = validate_choices("four", "test_field", choices)
314
+ assert result2 == "four"
315
+
316
+ def test_validate_choices_empty_choices(self):
317
+ """Test choices validation with empty choices list"""
318
+ with pytest.raises(ValidationError, match="Field 'test_field' must be one of \\[\\], got 'value'"):
319
+ validate_choices("value", "test_field", [])
tests/unit/application/dtos/test_processing_request_dto.py ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for ProcessingRequestDto"""
2
+
3
+ import pytest
4
+
5
+ from src.application.dtos.processing_request_dto import ProcessingRequestDto
6
+ from src.application.dtos.audio_upload_dto import AudioUploadDto
7
+
8
+
9
+ class TestProcessingRequestDto:
10
+ """Test cases for ProcessingRequestDto"""
11
+
12
+ @pytest.fixture
13
+ def sample_audio_upload(self):
14
+ """Create sample audio upload DTO"""
15
+ return AudioUploadDto(
16
+ filename="test_audio.wav",
17
+ content=b"fake_audio_content_" + b"x" * 1000,
18
+ content_type="audio/wav"
19
+ )
20
+
21
+ def test_valid_processing_request_dto(self, sample_audio_upload):
22
+ """Test creating a valid ProcessingRequestDto"""
23
+ dto = ProcessingRequestDto(
24
+ audio=sample_audio_upload,
25
+ asr_model="whisper-small",
26
+ target_language="es",
27
+ voice="kokoro",
28
+ speed=1.0,
29
+ source_language="en"
30
+ )
31
+
32
+ assert dto.audio == sample_audio_upload
33
+ assert dto.asr_model == "whisper-small"
34
+ assert dto.target_language == "es"
35
+ assert dto.voice == "kokoro"
36
+ assert dto.speed == 1.0
37
+ assert dto.source_language == "en"
38
+ assert dto.additional_params == {}
39
+
40
+ def test_processing_request_dto_with_defaults(self, sample_audio_upload):
41
+ """Test creating ProcessingRequestDto with default values"""
42
+ dto = ProcessingRequestDto(
43
+ audio=sample_audio_upload,
44
+ asr_model="whisper-medium",
45
+ target_language="fr",
46
+ voice="dia"
47
+ )
48
+
49
+ assert dto.speed == 1.0 # Default speed
50
+ assert dto.source_language is None # Default source language
51
+ assert dto.additional_params == {} # Default additional params
52
+
53
+ def test_processing_request_dto_with_additional_params(self, sample_audio_upload):
54
+ """Test creating ProcessingRequestDto with additional parameters"""
55
+ additional_params = {
56
+ "custom_param": "value",
57
+ "another_param": 123
58
+ }
59
+
60
+ dto = ProcessingRequestDto(
61
+ audio=sample_audio_upload,
62
+ asr_model="whisper-large",
63
+ target_language="de",
64
+ voice="cosyvoice2",
65
+ additional_params=additional_params
66
+ )
67
+
68
+ assert dto.additional_params == additional_params
69
+
70
+ def test_invalid_audio_type_validation(self):
71
+ """Test validation with invalid audio type"""
72
+ with pytest.raises(ValueError, match="Audio must be an AudioUploadDto instance"):
73
+ ProcessingRequestDto(
74
+ audio="invalid_audio", # Not AudioUploadDto
75
+ asr_model="whisper-small",
76
+ target_language="es",
77
+ voice="kokoro"
78
+ )
79
+
80
+ def test_empty_asr_model_validation(self, sample_audio_upload):
81
+ """Test validation with empty ASR model"""
82
+ with pytest.raises(ValueError, match="ASR model cannot be empty"):
83
+ ProcessingRequestDto(
84
+ audio=sample_audio_upload,
85
+ asr_model="",
86
+ target_language="es",
87
+ voice="kokoro"
88
+ )
89
+
90
+ def test_unsupported_asr_model_validation(self, sample_audio_upload):
91
+ """Test validation with unsupported ASR model"""
92
+ with pytest.raises(ValueError, match="Unsupported ASR model"):
93
+ ProcessingRequestDto(
94
+ audio=sample_audio_upload,
95
+ asr_model="invalid-model",
96
+ target_language="es",
97
+ voice="kokoro"
98
+ )
99
+
100
+ def test_supported_asr_models(self, sample_audio_upload):
101
+ """Test all supported ASR models"""
102
+ supported_models = ['whisper-small', 'whisper-medium', 'whisper-large', 'parakeet']
103
+
104
+ for model in supported_models:
105
+ # Should not raise exception
106
+ dto = ProcessingRequestDto(
107
+ audio=sample_audio_upload,
108
+ asr_model=model,
109
+ target_language="es",
110
+ voice="kokoro"
111
+ )
112
+ assert dto.asr_model == model
113
+
114
+ def test_empty_target_language_validation(self, sample_audio_upload):
115
+ """Test validation with empty target language"""
116
+ with pytest.raises(ValueError, match="Target language cannot be empty"):
117
+ ProcessingRequestDto(
118
+ audio=sample_audio_upload,
119
+ asr_model="whisper-small",
120
+ target_language="",
121
+ voice="kokoro"
122
+ )
123
+
124
+ def test_unsupported_target_language_validation(self, sample_audio_upload):
125
+ """Test validation with unsupported target language"""
126
+ with pytest.raises(ValueError, match="Unsupported target language"):
127
+ ProcessingRequestDto(
128
+ audio=sample_audio_upload,
129
+ asr_model="whisper-small",
130
+ target_language="invalid-lang",
131
+ voice="kokoro"
132
+ )
133
+
134
+ def test_unsupported_source_language_validation(self, sample_audio_upload):
135
+ """Test validation with unsupported source language"""
136
+ with pytest.raises(ValueError, match="Unsupported source language"):
137
+ ProcessingRequestDto(
138
+ audio=sample_audio_upload,
139
+ asr_model="whisper-small",
140
+ target_language="es",
141
+ voice="kokoro",
142
+ source_language="invalid-lang"
143
+ )
144
+
145
+ def test_supported_languages(self, sample_audio_upload):
146
+ """Test all supported languages"""
147
+ supported_languages = [
148
+ 'en', 'es', 'fr', 'de', 'it', 'pt', 'ru', 'ja', 'ko', 'zh',
149
+ 'ar', 'hi', 'tr', 'pl', 'nl', 'sv', 'da', 'no', 'fi'
150
+ ]
151
+
152
+ for lang in supported_languages:
153
+ # Should not raise exception
154
+ dto = ProcessingRequestDto(
155
+ audio=sample_audio_upload,
156
+ asr_model="whisper-small",
157
+ target_language=lang,
158
+ voice="kokoro",
159
+ source_language=lang
160
+ )
161
+ assert dto.target_language == lang
162
+ assert dto.source_language == lang
163
+
164
+ def test_empty_voice_validation(self, sample_audio_upload):
165
+ """Test validation with empty voice"""
166
+ with pytest.raises(ValueError, match="Voice cannot be empty"):
167
+ ProcessingRequestDto(
168
+ audio=sample_audio_upload,
169
+ asr_model="whisper-small",
170
+ target_language="es",
171
+ voice=""
172
+ )
173
+
174
+ def test_unsupported_voice_validation(self, sample_audio_upload):
175
+ """Test validation with unsupported voice"""
176
+ with pytest.raises(ValueError, match="Unsupported voice"):
177
+ ProcessingRequestDto(
178
+ audio=sample_audio_upload,
179
+ asr_model="whisper-small",
180
+ target_language="es",
181
+ voice="invalid-voice"
182
+ )
183
+
184
+ def test_supported_voices(self, sample_audio_upload):
185
+ """Test all supported voices"""
186
+ supported_voices = ['kokoro', 'dia', 'cosyvoice2', 'dummy']
187
+
188
+ for voice in supported_voices:
189
+ # Should not raise exception
190
+ dto = ProcessingRequestDto(
191
+ audio=sample_audio_upload,
192
+ asr_model="whisper-small",
193
+ target_language="es",
194
+ voice=voice
195
+ )
196
+ assert dto.voice == voice
197
+
198
+ def test_speed_range_validation_too_low(self, sample_audio_upload):
199
+ """Test validation with speed too low"""
200
+ with pytest.raises(ValueError, match="Speed must be between 0.5 and 2.0"):
201
+ ProcessingRequestDto(
202
+ audio=sample_audio_upload,
203
+ asr_model="whisper-small",
204
+ target_language="es",
205
+ voice="kokoro",
206
+ speed=0.3 # Too low
207
+ )
208
+
209
+ def test_speed_range_validation_too_high(self, sample_audio_upload):
210
+ """Test validation with speed too high"""
211
+ with pytest.raises(ValueError, match="Speed must be between 0.5 and 2.0"):
212
+ ProcessingRequestDto(
213
+ audio=sample_audio_upload,
214
+ asr_model="whisper-small",
215
+ target_language="es",
216
+ voice="kokoro",
217
+ speed=2.5 # Too high
218
+ )
219
+
220
+ def test_valid_speed_range(self, sample_audio_upload):
221
+ """Test valid speed range"""
222
+ valid_speeds = [0.5, 1.0, 1.5, 2.0]
223
+
224
+ for speed in valid_speeds:
225
+ # Should not raise exception
226
+ dto = ProcessingRequestDto(
227
+ audio=sample_audio_upload,
228
+ asr_model="whisper-small",
229
+ target_language="es",
230
+ voice="kokoro",
231
+ speed=speed
232
+ )
233
+ assert dto.speed == speed
234
+
235
+ def test_invalid_additional_params_type(self, sample_audio_upload):
236
+ """Test validation with invalid additional params type"""
237
+ with pytest.raises(ValueError, match="Additional params must be a dictionary"):
238
+ ProcessingRequestDto(
239
+ audio=sample_audio_upload,
240
+ asr_model="whisper-small",
241
+ target_language="es",
242
+ voice="kokoro",
243
+ additional_params="invalid" # Not a dict
244
+ )
245
+
246
+ def test_requires_translation_property_same_language(self, sample_audio_upload):
247
+ """Test requires_translation property when source and target are same"""
248
+ dto = ProcessingRequestDto(
249
+ audio=sample_audio_upload,
250
+ asr_model="whisper-small",
251
+ target_language="en",
252
+ voice="kokoro",
253
+ source_language="en"
254
+ )
255
+
256
+ assert dto.requires_translation is False
257
+
258
+ def test_requires_translation_property_different_languages(self, sample_audio_upload):
259
+ """Test requires_translation property when source and target are different"""
260
+ dto = ProcessingRequestDto(
261
+ audio=sample_audio_upload,
262
+ asr_model="whisper-small",
263
+ target_language="es",
264
+ voice="kokoro",
265
+ source_language="en"
266
+ )
267
+
268
+ assert dto.requires_translation is True
269
+
270
+ def test_requires_translation_property_no_source(self, sample_audio_upload):
271
+ """Test requires_translation property when no source language specified"""
272
+ dto = ProcessingRequestDto(
273
+ audio=sample_audio_upload,
274
+ asr_model="whisper-small",
275
+ target_language="es",
276
+ voice="kokoro"
277
+ )
278
+
279
+ assert dto.requires_translation is True # Assume translation needed
280
+
281
+ def test_to_dict_method(self, sample_audio_upload):
282
+ """Test to_dict method"""
283
+ dto = ProcessingRequestDto(
284
+ audio=sample_audio_upload,
285
+ asr_model="whisper-small",
286
+ target_language="es",
287
+ voice="kokoro",
288
+ speed=1.5,
289
+ source_language="en",
290
+ additional_params={"custom": "value"}
291
+ )
292
+
293
+ result = dto.to_dict()
294
+
295
+ assert result['audio'] == sample_audio_upload.to_dict()
296
+ assert result['asr_model'] == "whisper-small"
297
+ assert result['target_language'] == "es"
298
+ assert result['source_language'] == "en"
299
+ assert result['voice'] == "kokoro"
300
+ assert result['speed'] == 1.5
301
+ assert result['requires_translation'] is True
302
+ assert result['additional_params'] == {"custom": "value"}
303
+
304
+ def test_from_dict_method(self, sample_audio_upload):
305
+ """Test from_dict class method"""
306
+ data = {
307
+ 'audio': sample_audio_upload,
308
+ 'asr_model': 'whisper-medium',
309
+ 'target_language': 'fr',
310
+ 'voice': 'dia',
311
+ 'speed': 1.2,
312
+ 'source_language': 'en',
313
+ 'additional_params': {'test': 'value'}
314
+ }
315
+
316
+ dto = ProcessingRequestDto.from_dict(data)
317
+
318
+ assert dto.audio == sample_audio_upload
319
+ assert dto.asr_model == 'whisper-medium'
320
+ assert dto.target_language == 'fr'
321
+ assert dto.voice == 'dia'
322
+ assert dto.speed == 1.2
323
+ assert dto.source_language == 'en'
324
+ assert dto.additional_params == {'test': 'value'}
325
+
326
+ def test_from_dict_method_with_audio_dict(self):
327
+ """Test from_dict method with audio as dictionary"""
328
+ audio_data = {
329
+ 'filename': 'test.wav',
330
+ 'content': b'fake_content' + b'x' * 1000,
331
+ 'content_type': 'audio/wav'
332
+ }
333
+
334
+ data = {
335
+ 'audio': audio_data,
336
+ 'asr_model': 'whisper-small',
337
+ 'target_language': 'es',
338
+ 'voice': 'kokoro'
339
+ }
340
+
341
+ dto = ProcessingRequestDto.from_dict(data)
342
+
343
+ assert isinstance(dto.audio, AudioUploadDto)
344
+ assert dto.audio.filename == 'test.wav'
345
+ assert dto.audio.content_type == 'audio/wav'
346
+
347
+ def test_from_dict_method_with_defaults(self, sample_audio_upload):
348
+ """Test from_dict method with default values"""
349
+ data = {
350
+ 'audio': sample_audio_upload,
351
+ 'asr_model': 'whisper-small',
352
+ 'target_language': 'es',
353
+ 'voice': 'kokoro'
354
+ }
355
+
356
+ dto = ProcessingRequestDto.from_dict(data)
357
+
358
+ assert dto.speed == 1.0 # Default
359
+ assert dto.source_language is None # Default
360
+ assert dto.additional_params is None # Default
361
+
362
+ def test_validation_called_on_init(self, sample_audio_upload):
363
+ """Test that validation is called during initialization"""
364
+ # This should trigger validation and raise an error
365
+ with pytest.raises(ValueError):
366
+ ProcessingRequestDto(
367
+ audio=sample_audio_upload,
368
+ asr_model="", # Invalid empty model
369
+ target_language="es",
370
+ voice="kokoro"
371
+ )
372
+
373
+ def test_additional_params_default_initialization(self, sample_audio_upload):
374
+ """Test that additional_params is initialized to empty dict if None"""
375
+ dto = ProcessingRequestDto(
376
+ audio=sample_audio_upload,
377
+ asr_model="whisper-small",
378
+ target_language="es",
379
+ voice="kokoro",
380
+ additional_params=None
381
+ )
382
+
383
+ assert dto.additional_params == {}
tests/unit/application/dtos/test_processing_result_dto.py ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for ProcessingResultDto"""
2
+
3
+ import pytest
4
+ from datetime import datetime
5
+
6
+ from src.application.dtos.processing_result_dto import ProcessingResultDto
7
+
8
+
9
+ class TestProcessingResultDto:
10
+ """Test cases for ProcessingResultDto"""
11
+
12
+ def test_valid_success_processing_result_dto(self):
13
+ """Test creating a valid successful ProcessingResultDto"""
14
+ dto = ProcessingResultDto(
15
+ success=True,
16
+ original_text="Hello world",
17
+ translated_text="Hola mundo",
18
+ audio_path="/tmp/output.wav",
19
+ processing_time=5.2,
20
+ metadata={"correlation_id": "test-123"}
21
+ )
22
+
23
+ assert dto.success is True
24
+ assert dto.original_text == "Hello world"
25
+ assert dto.translated_text == "Hola mundo"
26
+ assert dto.audio_path == "/tmp/output.wav"
27
+ assert dto.processing_time == 5.2
28
+ assert dto.metadata == {"correlation_id": "test-123"}
29
+ assert dto.error_message is None
30
+ assert dto.error_code is None
31
+ assert isinstance(dto.timestamp, datetime)
32
+
33
+ def test_valid_error_processing_result_dto(self):
34
+ """Test creating a valid error ProcessingResultDto"""
35
+ dto = ProcessingResultDto(
36
+ success=False,
37
+ error_message="Processing failed",
38
+ error_code="STT_ERROR",
39
+ processing_time=2.1,
40
+ metadata={"correlation_id": "test-456"}
41
+ )
42
+
43
+ assert dto.success is False
44
+ assert dto.error_message == "Processing failed"
45
+ assert dto.error_code == "STT_ERROR"
46
+ assert dto.processing_time == 2.1
47
+ assert dto.metadata == {"correlation_id": "test-456"}
48
+ assert dto.original_text is None
49
+ assert dto.translated_text is None
50
+ assert dto.audio_path is None
51
+ assert isinstance(dto.timestamp, datetime)
52
+
53
+ def test_processing_result_dto_with_defaults(self):
54
+ """Test creating ProcessingResultDto with default values"""
55
+ dto = ProcessingResultDto(success=True, original_text="Test")
56
+
57
+ assert dto.success is True
58
+ assert dto.original_text == "Test"
59
+ assert dto.translated_text is None
60
+ assert dto.audio_path is None
61
+ assert dto.processing_time == 0.0
62
+ assert dto.error_message is None
63
+ assert dto.error_code is None
64
+ assert dto.metadata == {}
65
+ assert isinstance(dto.timestamp, datetime)
66
+
67
+ def test_processing_result_dto_with_explicit_timestamp(self):
68
+ """Test creating ProcessingResultDto with explicit timestamp"""
69
+ test_timestamp = datetime(2023, 1, 1, 12, 0, 0)
70
+
71
+ dto = ProcessingResultDto(
72
+ success=True,
73
+ original_text="Test",
74
+ timestamp=test_timestamp
75
+ )
76
+
77
+ assert dto.timestamp == test_timestamp
78
+
79
+ def test_invalid_success_type_validation(self):
80
+ """Test validation with invalid success type"""
81
+ with pytest.raises(ValueError, match="Success must be a boolean value"):
82
+ ProcessingResultDto(
83
+ success="true", # String instead of boolean
84
+ original_text="Test"
85
+ )
86
+
87
+ def test_negative_processing_time_validation(self):
88
+ """Test validation with negative processing time"""
89
+ with pytest.raises(ValueError, match="Processing time cannot be negative"):
90
+ ProcessingResultDto(
91
+ success=True,
92
+ original_text="Test",
93
+ processing_time=-1.0
94
+ )
95
+
96
+ def test_successful_processing_without_output_validation(self):
97
+ """Test validation for successful processing without any output"""
98
+ with pytest.raises(ValueError, match="Successful processing must have at least one output"):
99
+ ProcessingResultDto(
100
+ success=True,
101
+ # No original_text, translated_text, or audio_path
102
+ )
103
+
104
+ def test_failed_processing_without_error_message_validation(self):
105
+ """Test validation for failed processing without error message"""
106
+ with pytest.raises(ValueError, match="Failed processing must include an error message"):
107
+ ProcessingResultDto(
108
+ success=False,
109
+ # No error_message
110
+ )
111
+
112
+ def test_invalid_error_code_validation(self):
113
+ """Test validation with invalid error code"""
114
+ with pytest.raises(ValueError, match="Invalid error code"):
115
+ ProcessingResultDto(
116
+ success=False,
117
+ error_message="Test error",
118
+ error_code="INVALID_CODE"
119
+ )
120
+
121
+ def test_valid_error_codes(self):
122
+ """Test all valid error codes"""
123
+ valid_error_codes = [
124
+ 'STT_ERROR', 'TRANSLATION_ERROR', 'TTS_ERROR',
125
+ 'AUDIO_FORMAT_ERROR', 'VALIDATION_ERROR', 'SYSTEM_ERROR'
126
+ ]
127
+
128
+ for error_code in valid_error_codes:
129
+ # Should not raise exception
130
+ dto = ProcessingResultDto(
131
+ success=False,
132
+ error_message="Test error",
133
+ error_code=error_code
134
+ )
135
+ assert dto.error_code == error_code
136
+
137
+ def test_invalid_metadata_type_validation(self):
138
+ """Test validation with invalid metadata type"""
139
+ with pytest.raises(ValueError, match="Metadata must be a dictionary"):
140
+ ProcessingResultDto(
141
+ success=True,
142
+ original_text="Test",
143
+ metadata="invalid" # String instead of dict
144
+ )
145
+
146
+ def test_has_text_output_property(self):
147
+ """Test has_text_output property"""
148
+ # With original text only
149
+ dto1 = ProcessingResultDto(success=True, original_text="Test")
150
+ assert dto1.has_text_output is True
151
+
152
+ # With translated text only
153
+ dto2 = ProcessingResultDto(success=True, translated_text="Prueba")
154
+ assert dto2.has_text_output is True
155
+
156
+ # With both texts
157
+ dto3 = ProcessingResultDto(success=True, original_text="Test", translated_text="Prueba")
158
+ assert dto3.has_text_output is True
159
+
160
+ # With no text
161
+ dto4 = ProcessingResultDto(success=True, audio_path="/tmp/test.wav")
162
+ assert dto4.has_text_output is False
163
+
164
+ def test_has_audio_output_property(self):
165
+ """Test has_audio_output property"""
166
+ # With audio path
167
+ dto1 = ProcessingResultDto(success=True, audio_path="/tmp/test.wav")
168
+ assert dto1.has_audio_output is True
169
+
170
+ # Without audio path
171
+ dto2 = ProcessingResultDto(success=True, original_text="Test")
172
+ assert dto2.has_audio_output is False
173
+
174
+ def test_is_complete_property(self):
175
+ """Test is_complete property"""
176
+ # Successful processing
177
+ dto1 = ProcessingResultDto(success=True, original_text="Test")
178
+ assert dto1.is_complete is True
179
+
180
+ # Failed processing with error message
181
+ dto2 = ProcessingResultDto(success=False, error_message="Error")
182
+ assert dto2.is_complete is True
183
+
184
+ # This shouldn't happen due to validation, but test the logic
185
+ dto3 = ProcessingResultDto.__new__(ProcessingResultDto)
186
+ dto3.success = False
187
+ dto3.error_message = None
188
+ assert dto3.is_complete is False
189
+
190
+ def test_add_metadata_method(self):
191
+ """Test add_metadata method"""
192
+ dto = ProcessingResultDto(success=True, original_text="Test")
193
+
194
+ dto.add_metadata("key1", "value1")
195
+ dto.add_metadata("key2", 123)
196
+
197
+ assert dto.metadata["key1"] == "value1"
198
+ assert dto.metadata["key2"] == 123
199
+
200
+ def test_add_metadata_method_with_none_metadata(self):
201
+ """Test add_metadata method when metadata is None"""
202
+ dto = ProcessingResultDto.__new__(ProcessingResultDto)
203
+ dto.success = True
204
+ dto.original_text = "Test"
205
+ dto.metadata = None
206
+
207
+ dto.add_metadata("key", "value")
208
+
209
+ assert dto.metadata == {"key": "value"}
210
+
211
+ def test_get_metadata_method(self):
212
+ """Test get_metadata method"""
213
+ dto = ProcessingResultDto(
214
+ success=True,
215
+ original_text="Test",
216
+ metadata={"existing_key": "existing_value"}
217
+ )
218
+
219
+ # Get existing key
220
+ assert dto.get_metadata("existing_key") == "existing_value"
221
+
222
+ # Get non-existing key with default
223
+ assert dto.get_metadata("non_existing", "default") == "default"
224
+
225
+ # Get non-existing key without default
226
+ assert dto.get_metadata("non_existing") is None
227
+
228
+ def test_get_metadata_method_with_none_metadata(self):
229
+ """Test get_metadata method when metadata is None"""
230
+ dto = ProcessingResultDto.__new__(ProcessingResultDto)
231
+ dto.success = True
232
+ dto.original_text = "Test"
233
+ dto.metadata = None
234
+
235
+ assert dto.get_metadata("key", "default") == "default"
236
+ assert dto.get_metadata("key") is None
237
+
238
+ def test_to_dict_method(self):
239
+ """Test to_dict method"""
240
+ test_timestamp = datetime(2023, 1, 1, 12, 0, 0)
241
+
242
+ dto = ProcessingResultDto(
243
+ success=True,
244
+ original_text="Hello world",
245
+ translated_text="Hola mundo",
246
+ audio_path="/tmp/output.wav",
247
+ processing_time=5.2,
248
+ error_message=None,
249
+ error_code=None,
250
+ metadata={"correlation_id": "test-123"},
251
+ timestamp=test_timestamp
252
+ )
253
+
254
+ result = dto.to_dict()
255
+
256
+ assert result['success'] is True
257
+ assert result['original_text'] == "Hello world"
258
+ assert result['translated_text'] == "Hola mundo"
259
+ assert result['audio_path'] == "/tmp/output.wav"
260
+ assert result['processing_time'] == 5.2
261
+ assert result['error_message'] is None
262
+ assert result['error_code'] is None
263
+ assert result['metadata'] == {"correlation_id": "test-123"}
264
+ assert result['timestamp'] == test_timestamp.isoformat()
265
+ assert result['has_text_output'] is True
266
+ assert result['has_audio_output'] is True
267
+ assert result['is_complete'] is True
268
+
269
+ def test_to_dict_method_with_none_timestamp(self):
270
+ """Test to_dict method with None timestamp"""
271
+ dto = ProcessingResultDto.__new__(ProcessingResultDto)
272
+ dto.success = True
273
+ dto.original_text = "Test"
274
+ dto.translated_text = None
275
+ dto.audio_path = None
276
+ dto.processing_time = 0.0
277
+ dto.error_message = None
278
+ dto.error_code = None
279
+ dto.metadata = {}
280
+ dto.timestamp = None
281
+
282
+ result = dto.to_dict()
283
+
284
+ assert result['timestamp'] is None
285
+
286
+ def test_success_result_class_method(self):
287
+ """Test success_result class method"""
288
+ result = ProcessingResultDto.success_result(
289
+ original_text="Hello world",
290
+ translated_text="Hola mundo",
291
+ audio_path="/tmp/output.wav",
292
+ processing_time=3.5,
293
+ metadata={"test": "value"}
294
+ )
295
+
296
+ assert isinstance(result, ProcessingResultDto)
297
+ assert result.success is True
298
+ assert result.original_text == "Hello world"
299
+ assert result.translated_text == "Hola mundo"
300
+ assert result.audio_path == "/tmp/output.wav"
301
+ assert result.processing_time == 3.5
302
+ assert result.metadata == {"test": "value"}
303
+ assert result.error_message is None
304
+ assert result.error_code is None
305
+
306
+ def test_success_result_class_method_with_defaults(self):
307
+ """Test success_result class method with default values"""
308
+ result = ProcessingResultDto.success_result(original_text="Test")
309
+
310
+ assert result.success is True
311
+ assert result.original_text == "Test"
312
+ assert result.translated_text is None
313
+ assert result.audio_path is None
314
+ assert result.processing_time == 0.0
315
+ assert result.metadata is None
316
+
317
+ def test_error_result_class_method(self):
318
+ """Test error_result class method"""
319
+ result = ProcessingResultDto.error_result(
320
+ error_message="Processing failed",
321
+ error_code="STT_ERROR",
322
+ processing_time=2.1,
323
+ metadata={"correlation_id": "test-456"}
324
+ )
325
+
326
+ assert isinstance(result, ProcessingResultDto)
327
+ assert result.success is False
328
+ assert result.error_message == "Processing failed"
329
+ assert result.error_code == "STT_ERROR"
330
+ assert result.processing_time == 2.1
331
+ assert result.metadata == {"correlation_id": "test-456"}
332
+ assert result.original_text is None
333
+ assert result.translated_text is None
334
+ assert result.audio_path is None
335
+
336
+ def test_error_result_class_method_with_defaults(self):
337
+ """Test error_result class method with default values"""
338
+ result = ProcessingResultDto.error_result("Test error")
339
+
340
+ assert result.success is False
341
+ assert result.error_message == "Test error"
342
+ assert result.error_code is None
343
+ assert result.processing_time == 0.0
344
+ assert result.metadata is None
345
+
346
+ def test_from_dict_class_method(self):
347
+ """Test from_dict class method"""
348
+ test_timestamp = datetime(2023, 1, 1, 12, 0, 0)
349
+
350
+ data = {
351
+ 'success': True,
352
+ 'original_text': 'Hello world',
353
+ 'translated_text': 'Hola mundo',
354
+ 'audio_path': '/tmp/output.wav',
355
+ 'processing_time': 5.2,
356
+ 'error_message': None,
357
+ 'error_code': None,
358
+ 'metadata': {'correlation_id': 'test-123'},
359
+ 'timestamp': test_timestamp.isoformat()
360
+ }
361
+
362
+ dto = ProcessingResultDto.from_dict(data)
363
+
364
+ assert dto.success is True
365
+ assert dto.original_text == 'Hello world'
366
+ assert dto.translated_text == 'Hola mundo'
367
+ assert dto.audio_path == '/tmp/output.wav'
368
+ assert dto.processing_time == 5.2
369
+ assert dto.error_message is None
370
+ assert dto.error_code is None
371
+ assert dto.metadata == {'correlation_id': 'test-123'}
372
+ assert dto.timestamp == test_timestamp
373
+
374
+ def test_from_dict_class_method_with_z_timestamp(self):
375
+ """Test from_dict class method with Z-suffixed timestamp"""
376
+ data = {
377
+ 'success': True,
378
+ 'original_text': 'Test',
379
+ 'timestamp': '2023-01-01T12:00:00Z'
380
+ }
381
+
382
+ dto = ProcessingResultDto.from_dict(data)
383
+
384
+ assert dto.timestamp == datetime(2023, 1, 1, 12, 0, 0)
385
+
386
+ def test_from_dict_class_method_with_defaults(self):
387
+ """Test from_dict class method with default values"""
388
+ data = {
389
+ 'success': True,
390
+ 'original_text': 'Test'
391
+ }
392
+
393
+ dto = ProcessingResultDto.from_dict(data)
394
+
395
+ assert dto.success is True
396
+ assert dto.original_text == 'Test'
397
+ assert dto.translated_text is None
398
+ assert dto.audio_path is None
399
+ assert dto.processing_time == 0.0
400
+ assert dto.error_message is None
401
+ assert dto.error_code is None
402
+ assert dto.metadata is None
403
+ assert dto.timestamp is None
404
+
405
+ def test_validation_called_on_init(self):
406
+ """Test that validation is called during initialization"""
407
+ # This should trigger validation and raise an error
408
+ with pytest.raises(ValueError):
409
+ ProcessingResultDto(
410
+ success="invalid", # Invalid type
411
+ original_text="Test"
412
+ )
413
+
414
+ def test_metadata_default_initialization(self):
415
+ """Test that metadata is initialized to empty dict if None"""
416
+ dto = ProcessingResultDto(
417
+ success=True,
418
+ original_text="Test",
419
+ metadata=None
420
+ )
421
+
422
+ assert dto.metadata == {}
423
+
424
+ def test_timestamp_default_initialization(self):
425
+ """Test that timestamp is initialized to current time if None"""
426
+ before = datetime.utcnow()
427
+
428
+ dto = ProcessingResultDto(
429
+ success=True,
430
+ original_text="Test",
431
+ timestamp=None
432
+ )
433
+
434
+ after = datetime.utcnow()
435
+
436
+ assert before <= dto.timestamp <= after
tests/unit/application/services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Unit tests for application services"""
tests/unit/application/services/test_audio_processing_service.py ADDED
@@ -0,0 +1,475 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for AudioProcessingApplicationService"""
2
+
3
+ import pytest
4
+ import tempfile
5
+ import os
6
+ import time
7
+ from unittest.mock import Mock, MagicMock, patch, call
8
+ from contextlib import contextmanager
9
+
10
+ from src.application.services.audio_processing_service import AudioProcessingApplicationService
11
+ from src.application.dtos.audio_upload_dto import AudioUploadDto
12
+ from src.application.dtos.processing_request_dto import ProcessingRequestDto
13
+ from src.application.dtos.processing_result_dto import ProcessingResultDto
14
+ from src.domain.models.audio_content import AudioContent
15
+ from src.domain.models.text_content import TextContent
16
+ from src.domain.models.translation_request import TranslationRequest
17
+ from src.domain.models.speech_synthesis_request import SpeechSynthesisRequest
18
+ from src.domain.models.voice_settings import VoiceSettings
19
+ from src.domain.exceptions import (
20
+ AudioProcessingException,
21
+ SpeechRecognitionException,
22
+ TranslationFailedException,
23
+ SpeechSynthesisException
24
+ )
25
+ from src.infrastructure.config.app_config import AppConfig
26
+ from src.infrastructure.config.dependency_container import DependencyContainer
27
+
28
+
29
+ class TestAudioProcessingApplicationService:
30
+ """Test cases for AudioProcessingApplicationService"""
31
+
32
+ @pytest.fixture
33
+ def mock_container(self):
34
+ """Create mock dependency container"""
35
+ container = Mock(spec=DependencyContainer)
36
+
37
+ # Mock providers
38
+ mock_stt_provider = Mock()
39
+ mock_translation_provider = Mock()
40
+ mock_tts_provider = Mock()
41
+
42
+ container.get_stt_provider.return_value = mock_stt_provider
43
+ container.get_translation_provider.return_value = mock_translation_provider
44
+ container.get_tts_provider.return_value = mock_tts_provider
45
+
46
+ return container
47
+
48
+ @pytest.fixture
49
+ def mock_config(self):
50
+ """Create mock application config"""
51
+ config = Mock(spec=AppConfig)
52
+
53
+ # Mock configuration methods
54
+ config.get_logging_config.return_value = {
55
+ 'level': 'INFO',
56
+ 'enable_file_logging': False,
57
+ 'log_file_path': '/tmp/test.log',
58
+ 'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
59
+ }
60
+
61
+ config.get_processing_config.return_value = {
62
+ 'max_file_size_mb': 100,
63
+ 'supported_audio_formats': ['wav', 'mp3', 'flac', 'ogg', 'm4a'],
64
+ 'temp_dir': '/tmp',
65
+ 'cleanup_temp_files': True,
66
+ 'processing_timeout_seconds': 300
67
+ }
68
+
69
+ config.get_stt_config.return_value = {
70
+ 'preferred_providers': ['whisper-small', 'whisper-medium']
71
+ }
72
+
73
+ config.get_tts_config.return_value = {
74
+ 'preferred_providers': ['kokoro', 'dia']
75
+ }
76
+
77
+ return config
78
+
79
+ @pytest.fixture
80
+ def sample_audio_upload(self):
81
+ """Create sample audio upload DTO"""
82
+ return AudioUploadDto(
83
+ filename="test_audio.wav",
84
+ content=b"fake_audio_content_" + b"x" * 1000, # 1KB+ of fake audio
85
+ content_type="audio/wav"
86
+ )
87
+
88
+ @pytest.fixture
89
+ def sample_processing_request(self, sample_audio_upload):
90
+ """Create sample processing request DTO"""
91
+ return ProcessingRequestDto(
92
+ audio=sample_audio_upload,
93
+ asr_model="whisper-small",
94
+ target_language="es",
95
+ voice="kokoro",
96
+ speed=1.0,
97
+ source_language="en"
98
+ )
99
+
100
+ @pytest.fixture
101
+ def service(self, mock_container, mock_config):
102
+ """Create AudioProcessingApplicationService instance"""
103
+ mock_container.resolve.return_value = mock_config
104
+ return AudioProcessingApplicationService(mock_container, mock_config)
105
+
106
+ def test_initialization(self, mock_container, mock_config):
107
+ """Test service initialization"""
108
+ service = AudioProcessingApplicationService(mock_container, mock_config)
109
+
110
+ assert service._container == mock_container
111
+ assert service._config == mock_config
112
+ assert service._temp_files == {}
113
+ assert service._error_mapper is not None
114
+ assert service._recovery_manager is not None
115
+
116
+ def test_initialization_without_config(self, mock_container, mock_config):
117
+ """Test service initialization without explicit config"""
118
+ mock_container.resolve.return_value = mock_config
119
+
120
+ service = AudioProcessingApplicationService(mock_container)
121
+
122
+ assert service._container == mock_container
123
+ assert service._config == mock_config
124
+ mock_container.resolve.assert_called_once_with(AppConfig)
125
+
126
+ @patch('src.application.services.audio_processing_service.get_structured_logger')
127
+ def test_setup_logging_success(self, mock_logger, service, mock_config):
128
+ """Test successful logging setup"""
129
+ mock_config.get_logging_config.return_value = {
130
+ 'level': 'DEBUG',
131
+ 'enable_file_logging': True,
132
+ 'log_file_path': '/tmp/test.log',
133
+ 'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
134
+ }
135
+
136
+ service._setup_logging()
137
+
138
+ # Verify logging configuration was retrieved
139
+ mock_config.get_logging_config.assert_called_once()
140
+
141
+ @patch('src.application.services.audio_processing_service.get_structured_logger')
142
+ def test_setup_logging_failure(self, mock_logger, service, mock_config):
143
+ """Test logging setup failure handling"""
144
+ mock_config.get_logging_config.side_effect = Exception("Config error")
145
+
146
+ # Should not raise exception
147
+ service._setup_logging()
148
+
149
+ # Warning should be logged
150
+ mock_logger.return_value.warning.assert_called_once()
151
+
152
+ def test_validate_request_success(self, service, sample_processing_request):
153
+ """Test successful request validation"""
154
+ # Should not raise exception
155
+ service._validate_request(sample_processing_request)
156
+
157
+ def test_validate_request_invalid_type(self, service):
158
+ """Test request validation with invalid type"""
159
+ with pytest.raises(ValueError, match="Request must be a ProcessingRequestDto instance"):
160
+ service._validate_request("invalid_request")
161
+
162
+ def test_validate_request_file_too_large(self, service, sample_processing_request, mock_config):
163
+ """Test request validation with file too large"""
164
+ mock_config.get_processing_config.return_value['max_file_size_mb'] = 0.001 # Very small limit
165
+
166
+ with pytest.raises(ValueError, match="Audio file too large"):
167
+ service._validate_request(sample_processing_request)
168
+
169
+ def test_validate_request_unsupported_format(self, service, sample_processing_request, mock_config):
170
+ """Test request validation with unsupported format"""
171
+ sample_processing_request.audio.filename = "test.xyz"
172
+ mock_config.get_processing_config.return_value['supported_audio_formats'] = ['wav', 'mp3']
173
+
174
+ with pytest.raises(ValueError, match="Unsupported audio format"):
175
+ service._validate_request(sample_processing_request)
176
+
177
+ @patch('os.makedirs')
178
+ @patch('shutil.rmtree')
179
+ def test_create_temp_directory(self, mock_rmtree, mock_makedirs, service):
180
+ """Test temporary directory creation and cleanup"""
181
+ correlation_id = "test-123"
182
+
183
+ with service._create_temp_directory(correlation_id) as temp_dir:
184
+ assert correlation_id in temp_dir
185
+ mock_makedirs.assert_called_once()
186
+
187
+ # Cleanup should be called
188
+ mock_rmtree.assert_called_once()
189
+
190
+ @patch('builtins.open', create=True)
191
+ def test_convert_upload_to_audio_content_success(self, mock_open, service, sample_audio_upload):
192
+ """Test successful audio upload conversion"""
193
+ temp_dir = "/tmp/test"
194
+ mock_file = MagicMock()
195
+ mock_open.return_value.__enter__.return_value = mock_file
196
+
197
+ result = service._convert_upload_to_audio_content(sample_audio_upload, temp_dir)
198
+
199
+ assert isinstance(result, AudioContent)
200
+ assert result.data == sample_audio_upload.content
201
+ assert result.format == "wav"
202
+ mock_file.write.assert_called_once_with(sample_audio_upload.content)
203
+
204
+ @patch('builtins.open', side_effect=IOError("File error"))
205
+ def test_convert_upload_to_audio_content_failure(self, mock_open, service, sample_audio_upload):
206
+ """Test audio upload conversion failure"""
207
+ temp_dir = "/tmp/test"
208
+
209
+ with pytest.raises(AudioProcessingException, match="Failed to process uploaded audio"):
210
+ service._convert_upload_to_audio_content(sample_audio_upload, temp_dir)
211
+
212
+ def test_perform_speech_recognition_success(self, service, mock_container):
213
+ """Test successful speech recognition"""
214
+ audio = AudioContent(data=b"audio", format="wav", sample_rate=16000, duration=1.0)
215
+ model = "whisper-small"
216
+ correlation_id = "test-123"
217
+
218
+ # Mock STT provider
219
+ mock_stt_provider = Mock()
220
+ expected_text = TextContent(text="Hello world", language="en")
221
+ mock_stt_provider.transcribe.return_value = expected_text
222
+ mock_container.get_stt_provider.return_value = mock_stt_provider
223
+
224
+ result = service._perform_speech_recognition(audio, model, correlation_id)
225
+
226
+ assert result == expected_text
227
+ mock_container.get_stt_provider.assert_called_once_with(model)
228
+ mock_stt_provider.transcribe.assert_called_once_with(audio, model)
229
+
230
+ def test_perform_speech_recognition_failure(self, service, mock_container):
231
+ """Test speech recognition failure"""
232
+ audio = AudioContent(data=b"audio", format="wav", sample_rate=16000, duration=1.0)
233
+ model = "whisper-small"
234
+ correlation_id = "test-123"
235
+
236
+ # Mock STT provider to raise exception
237
+ mock_stt_provider = Mock()
238
+ mock_stt_provider.transcribe.side_effect = Exception("STT failed")
239
+ mock_container.get_stt_provider.return_value = mock_stt_provider
240
+
241
+ with pytest.raises(SpeechRecognitionException, match="Speech recognition failed"):
242
+ service._perform_speech_recognition(audio, model, correlation_id)
243
+
244
+ def test_perform_translation_success(self, service, mock_container):
245
+ """Test successful translation"""
246
+ text = TextContent(text="Hello world", language="en")
247
+ source_language = "en"
248
+ target_language = "es"
249
+ correlation_id = "test-123"
250
+
251
+ # Mock translation provider
252
+ mock_translation_provider = Mock()
253
+ expected_text = TextContent(text="Hola mundo", language="es")
254
+ mock_translation_provider.translate.return_value = expected_text
255
+ mock_container.get_translation_provider.return_value = mock_translation_provider
256
+
257
+ result = service._perform_translation(text, source_language, target_language, correlation_id)
258
+
259
+ assert result == expected_text
260
+ mock_container.get_translation_provider.assert_called_once()
261
+ mock_translation_provider.translate.assert_called_once()
262
+
263
+ def test_perform_translation_failure(self, service, mock_container):
264
+ """Test translation failure"""
265
+ text = TextContent(text="Hello world", language="en")
266
+ source_language = "en"
267
+ target_language = "es"
268
+ correlation_id = "test-123"
269
+
270
+ # Mock translation provider to raise exception
271
+ mock_translation_provider = Mock()
272
+ mock_translation_provider.translate.side_effect = Exception("Translation failed")
273
+ mock_container.get_translation_provider.return_value = mock_translation_provider
274
+
275
+ with pytest.raises(TranslationFailedException, match="Translation failed"):
276
+ service._perform_translation(text, source_language, target_language, correlation_id)
277
+
278
+ @patch('builtins.open', create=True)
279
+ def test_perform_speech_synthesis_success(self, mock_open, service, mock_container):
280
+ """Test successful speech synthesis"""
281
+ text = TextContent(text="Hola mundo", language="es")
282
+ voice = "kokoro"
283
+ speed = 1.0
284
+ language = "es"
285
+ temp_dir = "/tmp/test"
286
+ correlation_id = "test-123"
287
+
288
+ # Mock TTS provider
289
+ mock_tts_provider = Mock()
290
+ mock_audio = AudioContent(data=b"synthesized_audio", format="wav", sample_rate=22050, duration=2.0)
291
+ mock_tts_provider.synthesize.return_value = mock_audio
292
+ mock_container.get_tts_provider.return_value = mock_tts_provider
293
+
294
+ # Mock file operations
295
+ mock_file = MagicMock()
296
+ mock_open.return_value.__enter__.return_value = mock_file
297
+
298
+ result = service._perform_speech_synthesis(text, voice, speed, language, temp_dir, correlation_id)
299
+
300
+ assert correlation_id in result
301
+ assert result.endswith(".wav")
302
+ mock_container.get_tts_provider.assert_called_once_with(voice)
303
+ mock_tts_provider.synthesize.assert_called_once()
304
+ mock_file.write.assert_called_once_with(mock_audio.data)
305
+
306
+ def test_perform_speech_synthesis_failure(self, service, mock_container):
307
+ """Test speech synthesis failure"""
308
+ text = TextContent(text="Hola mundo", language="es")
309
+ voice = "kokoro"
310
+ speed = 1.0
311
+ language = "es"
312
+ temp_dir = "/tmp/test"
313
+ correlation_id = "test-123"
314
+
315
+ # Mock TTS provider to raise exception
316
+ mock_tts_provider = Mock()
317
+ mock_tts_provider.synthesize.side_effect = Exception("TTS failed")
318
+ mock_container.get_tts_provider.return_value = mock_tts_provider
319
+
320
+ with pytest.raises(SpeechSynthesisException, match="Speech synthesis failed"):
321
+ service._perform_speech_synthesis(text, voice, speed, language, temp_dir, correlation_id)
322
+
323
+ def test_get_error_code_from_exception(self, service):
324
+ """Test error code mapping from exceptions"""
325
+ assert service._get_error_code_from_exception(SpeechRecognitionException("test")) == 'STT_ERROR'
326
+ assert service._get_error_code_from_exception(TranslationFailedException("test")) == 'TRANSLATION_ERROR'
327
+ assert service._get_error_code_from_exception(SpeechSynthesisException("test")) == 'TTS_ERROR'
328
+ assert service._get_error_code_from_exception(ValueError("test")) == 'VALIDATION_ERROR'
329
+ assert service._get_error_code_from_exception(Exception("test")) == 'SYSTEM_ERROR'
330
+
331
+ @patch('os.path.exists')
332
+ @patch('os.remove')
333
+ def test_cleanup_temp_files_success(self, mock_remove, mock_exists, service):
334
+ """Test successful temporary file cleanup"""
335
+ service._temp_files = {
336
+ "/tmp/file1.wav": "/tmp/file1.wav",
337
+ "/tmp/file2.wav": "/tmp/file2.wav"
338
+ }
339
+ mock_exists.return_value = True
340
+
341
+ service._cleanup_temp_files()
342
+
343
+ assert service._temp_files == {}
344
+ assert mock_remove.call_count == 2
345
+
346
+ @patch('os.path.exists')
347
+ @patch('os.remove', side_effect=OSError("Permission denied"))
348
+ def test_cleanup_temp_files_failure(self, mock_remove, mock_exists, service):
349
+ """Test temporary file cleanup with failures"""
350
+ service._temp_files = {"/tmp/file1.wav": "/tmp/file1.wav"}
351
+ mock_exists.return_value = True
352
+
353
+ # Should not raise exception
354
+ service._cleanup_temp_files()
355
+
356
+ # File should still be removed from tracking
357
+ assert service._temp_files == {}
358
+
359
+ def test_get_processing_status(self, service):
360
+ """Test processing status retrieval"""
361
+ correlation_id = "test-123"
362
+
363
+ result = service.get_processing_status(correlation_id)
364
+
365
+ assert result['correlation_id'] == correlation_id
366
+ assert 'status' in result
367
+ assert 'message' in result
368
+
369
+ def test_get_supported_configurations(self, service):
370
+ """Test supported configurations retrieval"""
371
+ result = service.get_supported_configurations()
372
+
373
+ assert 'asr_models' in result
374
+ assert 'voices' in result
375
+ assert 'languages' in result
376
+ assert 'audio_formats' in result
377
+ assert 'max_file_size_mb' in result
378
+ assert 'speed_range' in result
379
+
380
+ # Verify expected values
381
+ assert 'whisper-small' in result['asr_models']
382
+ assert 'kokoro' in result['voices']
383
+ assert 'en' in result['languages']
384
+
385
+ def test_cleanup(self, service):
386
+ """Test service cleanup"""
387
+ service._temp_files = {"/tmp/test.wav": "/tmp/test.wav"}
388
+
389
+ with patch.object(service, '_cleanup_temp_files') as mock_cleanup:
390
+ service.cleanup()
391
+ mock_cleanup.assert_called_once()
392
+
393
+ def test_context_manager(self, service):
394
+ """Test service as context manager"""
395
+ with patch.object(service, 'cleanup') as mock_cleanup:
396
+ with service as svc:
397
+ assert svc == service
398
+ mock_cleanup.assert_called_once()
399
+
400
+ @patch('src.application.services.audio_processing_service.time.time')
401
+ @patch.object(AudioProcessingApplicationService, '_create_temp_directory')
402
+ @patch.object(AudioProcessingApplicationService, '_convert_upload_to_audio_content')
403
+ @patch.object(AudioProcessingApplicationService, '_perform_speech_recognition_with_recovery')
404
+ @patch.object(AudioProcessingApplicationService, '_perform_translation_with_recovery')
405
+ @patch.object(AudioProcessingApplicationService, '_perform_speech_synthesis_with_recovery')
406
+ def test_process_audio_pipeline_success(self, mock_tts, mock_translation, mock_stt,
407
+ mock_convert, mock_temp_dir, mock_time,
408
+ service, sample_processing_request):
409
+ """Test successful audio processing pipeline"""
410
+ # Setup mocks
411
+ mock_time.side_effect = [0.0, 5.0] # Start and end times
412
+ mock_temp_dir.return_value.__enter__.return_value = "/tmp/test"
413
+ mock_temp_dir.return_value.__exit__.return_value = None
414
+
415
+ mock_audio = AudioContent(data=b"audio", format="wav", sample_rate=16000, duration=1.0)
416
+ mock_convert.return_value = mock_audio
417
+
418
+ mock_original_text = TextContent(text="Hello world", language="en")
419
+ mock_stt.return_value = mock_original_text
420
+
421
+ mock_translated_text = TextContent(text="Hola mundo", language="es")
422
+ mock_translation.return_value = mock_translated_text
423
+
424
+ mock_tts.return_value = "/tmp/test/output_123.wav"
425
+
426
+ with patch.object(service, '_validate_request'):
427
+ result = service.process_audio_pipeline(sample_processing_request)
428
+
429
+ # Verify result
430
+ assert isinstance(result, ProcessingResultDto)
431
+ assert result.success is True
432
+ assert result.original_text == "Hello world"
433
+ assert result.translated_text == "Hola mundo"
434
+ assert result.audio_path == "/tmp/test/output_123.wav"
435
+ assert result.processing_time == 5.0
436
+
437
+ @patch('src.application.services.audio_processing_service.time.time')
438
+ def test_process_audio_pipeline_validation_error(self, mock_time, service, sample_processing_request):
439
+ """Test audio processing pipeline with validation error"""
440
+ mock_time.side_effect = [0.0, 1.0]
441
+
442
+ with patch.object(service, '_validate_request', side_effect=ValueError("Invalid request")):
443
+ result = service.process_audio_pipeline(sample_processing_request)
444
+
445
+ # Verify error result
446
+ assert isinstance(result, ProcessingResultDto)
447
+ assert result.success is False
448
+ assert "Invalid request" in result.error_message
449
+ assert result.processing_time == 1.0
450
+
451
+ @patch('src.application.services.audio_processing_service.time.time')
452
+ def test_process_audio_pipeline_domain_exception(self, mock_time, service, sample_processing_request):
453
+ """Test audio processing pipeline with domain exception"""
454
+ mock_time.side_effect = [0.0, 2.0]
455
+
456
+ with patch.object(service, '_validate_request'):
457
+ with patch.object(service, '_create_temp_directory', side_effect=SpeechRecognitionException("STT failed")):
458
+ result = service.process_audio_pipeline(sample_processing_request)
459
+
460
+ # Verify error result
461
+ assert isinstance(result, ProcessingResultDto)
462
+ assert result.success is False
463
+ assert result.error_message is not None
464
+ assert result.processing_time == 2.0
465
+
466
+ def test_recovery_methods_exist(self, service):
467
+ """Test that recovery methods exist and are callable"""
468
+ # These methods should exist for error recovery
469
+ assert hasattr(service, '_perform_speech_recognition_with_recovery')
470
+ assert hasattr(service, '_perform_translation_with_recovery')
471
+ assert hasattr(service, '_perform_speech_synthesis_with_recovery')
472
+
473
+ assert callable(service._perform_speech_recognition_with_recovery)
474
+ assert callable(service._perform_translation_with_recovery)
475
+ assert callable(service._perform_speech_synthesis_with_recovery)
tests/unit/application/services/test_configuration_service.py ADDED
@@ -0,0 +1,572 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for ConfigurationApplicationService"""
2
+
3
+ import pytest
4
+ import os
5
+ import json
6
+ from unittest.mock import Mock, MagicMock, patch, mock_open
7
+
8
+ from src.application.services.configuration_service import (
9
+ ConfigurationApplicationService,
10
+ ConfigurationException
11
+ )
12
+ from src.infrastructure.config.app_config import AppConfig
13
+ from src.infrastructure.config.dependency_container import DependencyContainer
14
+
15
+
16
+ class TestConfigurationApplicationService:
17
+ """Test cases for ConfigurationApplicationService"""
18
+
19
+ @pytest.fixture
20
+ def mock_container(self):
21
+ """Create mock dependency container"""
22
+ container = Mock(spec=DependencyContainer)
23
+ return container
24
+
25
+ @pytest.fixture
26
+ def mock_config(self):
27
+ """Create mock application config"""
28
+ config = Mock(spec=AppConfig)
29
+
30
+ # Mock configuration methods
31
+ config.get_tts_config.return_value = {
32
+ 'preferred_providers': ['kokoro', 'dia'],
33
+ 'default_speed': 1.0,
34
+ 'default_language': 'en',
35
+ 'enable_streaming': False,
36
+ 'max_text_length': 5000
37
+ }
38
+
39
+ config.get_stt_config.return_value = {
40
+ 'preferred_providers': ['whisper', 'parakeet'],
41
+ 'default_model': 'whisper',
42
+ 'chunk_length_s': 30,
43
+ 'batch_size': 16,
44
+ 'enable_vad': True
45
+ }
46
+
47
+ config.get_translation_config.return_value = {
48
+ 'default_provider': 'nllb',
49
+ 'model_name': 'nllb-200-3.3B',
50
+ 'max_chunk_length': 512,
51
+ 'batch_size': 8,
52
+ 'cache_translations': True
53
+ }
54
+
55
+ config.get_processing_config.return_value = {
56
+ 'temp_dir': '/tmp',
57
+ 'cleanup_temp_files': True,
58
+ 'max_file_size_mb': 100,
59
+ 'supported_audio_formats': ['wav', 'mp3', 'flac'],
60
+ 'processing_timeout_seconds': 300
61
+ }
62
+
63
+ config.get_logging_config.return_value = {
64
+ 'level': 'INFO',
65
+ 'enable_file_logging': False,
66
+ 'log_file_path': '/tmp/app.log',
67
+ 'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
68
+ }
69
+
70
+ # Mock config objects for attribute access
71
+ config.tts = Mock()
72
+ config.stt = Mock()
73
+ config.translation = Mock()
74
+ config.processing = Mock()
75
+ config.logging = Mock()
76
+
77
+ return config
78
+
79
+ @pytest.fixture
80
+ def service(self, mock_container, mock_config):
81
+ """Create ConfigurationApplicationService instance"""
82
+ mock_container.resolve.return_value = mock_config
83
+ return ConfigurationApplicationService(mock_container, mock_config)
84
+
85
+ def test_initialization(self, mock_container, mock_config):
86
+ """Test service initialization"""
87
+ service = ConfigurationApplicationService(mock_container, mock_config)
88
+
89
+ assert service._container == mock_container
90
+ assert service._config == mock_config
91
+ assert service._error_mapper is not None
92
+
93
+ def test_initialization_without_config(self, mock_container, mock_config):
94
+ """Test service initialization without explicit config"""
95
+ mock_container.resolve.return_value = mock_config
96
+
97
+ service = ConfigurationApplicationService(mock_container)
98
+
99
+ assert service._container == mock_container
100
+ assert service._config == mock_config
101
+ mock_container.resolve.assert_called_once_with(AppConfig)
102
+
103
+ def test_get_current_configuration_success(self, service, mock_config):
104
+ """Test successful current configuration retrieval"""
105
+ result = service.get_current_configuration()
106
+
107
+ assert 'tts' in result
108
+ assert 'stt' in result
109
+ assert 'translation' in result
110
+ assert 'processing' in result
111
+ assert 'logging' in result
112
+
113
+ # Verify all config methods were called
114
+ mock_config.get_tts_config.assert_called_once()
115
+ mock_config.get_stt_config.assert_called_once()
116
+ mock_config.get_translation_config.assert_called_once()
117
+ mock_config.get_processing_config.assert_called_once()
118
+ mock_config.get_logging_config.assert_called_once()
119
+
120
+ def test_get_current_configuration_failure(self, service, mock_config):
121
+ """Test current configuration retrieval failure"""
122
+ mock_config.get_tts_config.side_effect = Exception("Config error")
123
+
124
+ with pytest.raises(ConfigurationException, match="Failed to retrieve configuration"):
125
+ service.get_current_configuration()
126
+
127
+ def test_get_tts_configuration_success(self, service, mock_config):
128
+ """Test successful TTS configuration retrieval"""
129
+ result = service.get_tts_configuration()
130
+
131
+ assert result['preferred_providers'] == ['kokoro', 'dia']
132
+ assert result['default_speed'] == 1.0
133
+ mock_config.get_tts_config.assert_called_once()
134
+
135
+ def test_get_tts_configuration_failure(self, service, mock_config):
136
+ """Test TTS configuration retrieval failure"""
137
+ mock_config.get_tts_config.side_effect = Exception("TTS config error")
138
+
139
+ with pytest.raises(ConfigurationException, match="Failed to retrieve TTS configuration"):
140
+ service.get_tts_configuration()
141
+
142
+ def test_get_stt_configuration_success(self, service, mock_config):
143
+ """Test successful STT configuration retrieval"""
144
+ result = service.get_stt_configuration()
145
+
146
+ assert result['preferred_providers'] == ['whisper', 'parakeet']
147
+ assert result['default_model'] == 'whisper'
148
+ mock_config.get_stt_config.assert_called_once()
149
+
150
+ def test_get_stt_configuration_failure(self, service, mock_config):
151
+ """Test STT configuration retrieval failure"""
152
+ mock_config.get_stt_config.side_effect = Exception("STT config error")
153
+
154
+ with pytest.raises(ConfigurationException, match="Failed to retrieve STT configuration"):
155
+ service.get_stt_configuration()
156
+
157
+ def test_get_translation_configuration_success(self, service, mock_config):
158
+ """Test successful translation configuration retrieval"""
159
+ result = service.get_translation_configuration()
160
+
161
+ assert result['default_provider'] == 'nllb'
162
+ assert result['model_name'] == 'nllb-200-3.3B'
163
+ mock_config.get_translation_config.assert_called_once()
164
+
165
+ def test_get_translation_configuration_failure(self, service, mock_config):
166
+ """Test translation configuration retrieval failure"""
167
+ mock_config.get_translation_config.side_effect = Exception("Translation config error")
168
+
169
+ with pytest.raises(ConfigurationException, match="Failed to retrieve translation configuration"):
170
+ service.get_translation_configuration()
171
+
172
+ def test_get_processing_configuration_success(self, service, mock_config):
173
+ """Test successful processing configuration retrieval"""
174
+ result = service.get_processing_configuration()
175
+
176
+ assert result['temp_dir'] == '/tmp'
177
+ assert result['max_file_size_mb'] == 100
178
+ mock_config.get_processing_config.assert_called_once()
179
+
180
+ def test_get_processing_configuration_failure(self, service, mock_config):
181
+ """Test processing configuration retrieval failure"""
182
+ mock_config.get_processing_config.side_effect = Exception("Processing config error")
183
+
184
+ with pytest.raises(ConfigurationException, match="Failed to retrieve processing configuration"):
185
+ service.get_processing_configuration()
186
+
187
+ def test_get_logging_configuration_success(self, service, mock_config):
188
+ """Test successful logging configuration retrieval"""
189
+ result = service.get_logging_configuration()
190
+
191
+ assert result['level'] == 'INFO'
192
+ assert result['enable_file_logging'] is False
193
+ mock_config.get_logging_config.assert_called_once()
194
+
195
+ def test_get_logging_configuration_failure(self, service, mock_config):
196
+ """Test logging configuration retrieval failure"""
197
+ mock_config.get_logging_config.side_effect = Exception("Logging config error")
198
+
199
+ with pytest.raises(ConfigurationException, match="Failed to retrieve logging configuration"):
200
+ service.get_logging_configuration()
201
+
202
+ def test_update_tts_configuration_success(self, service, mock_config):
203
+ """Test successful TTS configuration update"""
204
+ updates = {
205
+ 'default_speed': 1.5,
206
+ 'enable_streaming': True
207
+ }
208
+
209
+ result = service.update_tts_configuration(updates)
210
+
211
+ # Verify setattr was called for valid attributes
212
+ assert hasattr(mock_config.tts, 'default_speed')
213
+ assert hasattr(mock_config.tts, 'enable_streaming')
214
+
215
+ # Verify updated config was returned
216
+ mock_config.get_tts_config.assert_called()
217
+
218
+ def test_update_tts_configuration_validation_error(self, service):
219
+ """Test TTS configuration update with validation error"""
220
+ updates = {
221
+ 'default_speed': 5.0 # Invalid speed > 3.0
222
+ }
223
+
224
+ with pytest.raises(ConfigurationException, match="default_speed must be between 0.1 and 3.0"):
225
+ service.update_tts_configuration(updates)
226
+
227
+ def test_update_stt_configuration_success(self, service, mock_config):
228
+ """Test successful STT configuration update"""
229
+ updates = {
230
+ 'chunk_length_s': 60,
231
+ 'enable_vad': False
232
+ }
233
+
234
+ result = service.update_stt_configuration(updates)
235
+
236
+ # Verify setattr was called for valid attributes
237
+ assert hasattr(mock_config.stt, 'chunk_length_s')
238
+ assert hasattr(mock_config.stt, 'enable_vad')
239
+
240
+ # Verify updated config was returned
241
+ mock_config.get_stt_config.assert_called()
242
+
243
+ def test_update_stt_configuration_validation_error(self, service):
244
+ """Test STT configuration update with validation error"""
245
+ updates = {
246
+ 'chunk_length_s': -10 # Invalid negative value
247
+ }
248
+
249
+ with pytest.raises(ConfigurationException, match="chunk_length_s must be a positive integer"):
250
+ service.update_stt_configuration(updates)
251
+
252
+ def test_update_translation_configuration_success(self, service, mock_config):
253
+ """Test successful translation configuration update"""
254
+ updates = {
255
+ 'max_chunk_length': 1024,
256
+ 'cache_translations': False
257
+ }
258
+
259
+ result = service.update_translation_configuration(updates)
260
+
261
+ # Verify setattr was called for valid attributes
262
+ assert hasattr(mock_config.translation, 'max_chunk_length')
263
+ assert hasattr(mock_config.translation, 'cache_translations')
264
+
265
+ # Verify updated config was returned
266
+ mock_config.get_translation_config.assert_called()
267
+
268
+ def test_update_translation_configuration_validation_error(self, service):
269
+ """Test translation configuration update with validation error"""
270
+ updates = {
271
+ 'max_chunk_length': 0 # Invalid zero value
272
+ }
273
+
274
+ with pytest.raises(ConfigurationException, match="max_chunk_length must be a positive integer"):
275
+ service.update_translation_configuration(updates)
276
+
277
+ def test_update_processing_configuration_success(self, service, mock_config):
278
+ """Test successful processing configuration update"""
279
+ updates = {
280
+ 'max_file_size_mb': 200,
281
+ 'cleanup_temp_files': False
282
+ }
283
+
284
+ with patch('pathlib.Path.mkdir'):
285
+ result = service.update_processing_configuration(updates)
286
+
287
+ # Verify setattr was called for valid attributes
288
+ assert hasattr(mock_config.processing, 'max_file_size_mb')
289
+ assert hasattr(mock_config.processing, 'cleanup_temp_files')
290
+
291
+ # Verify updated config was returned
292
+ mock_config.get_processing_config.assert_called()
293
+
294
+ def test_update_processing_configuration_validation_error(self, service):
295
+ """Test processing configuration update with validation error"""
296
+ updates = {
297
+ 'max_file_size_mb': -50 # Invalid negative value
298
+ }
299
+
300
+ with pytest.raises(ConfigurationException, match="max_file_size_mb must be a positive integer"):
301
+ service.update_processing_configuration(updates)
302
+
303
+ def test_validate_tts_updates_valid(self, service):
304
+ """Test TTS updates validation with valid data"""
305
+ updates = {
306
+ 'preferred_providers': ['kokoro', 'dia'],
307
+ 'default_speed': 1.5,
308
+ 'default_language': 'es',
309
+ 'enable_streaming': True,
310
+ 'max_text_length': 10000
311
+ }
312
+
313
+ # Should not raise exception
314
+ service._validate_tts_updates(updates)
315
+
316
+ def test_validate_tts_updates_invalid_provider(self, service):
317
+ """Test TTS updates validation with invalid provider"""
318
+ updates = {
319
+ 'preferred_providers': ['invalid_provider']
320
+ }
321
+
322
+ with pytest.raises(ConfigurationException, match="Invalid TTS provider"):
323
+ service._validate_tts_updates(updates)
324
+
325
+ def test_validate_tts_updates_invalid_speed(self, service):
326
+ """Test TTS updates validation with invalid speed"""
327
+ updates = {
328
+ 'default_speed': 5.0 # Too high
329
+ }
330
+
331
+ with pytest.raises(ConfigurationException, match="default_speed must be between 0.1 and 3.0"):
332
+ service._validate_tts_updates(updates)
333
+
334
+ def test_validate_stt_updates_valid(self, service):
335
+ """Test STT updates validation with valid data"""
336
+ updates = {
337
+ 'preferred_providers': ['whisper', 'parakeet'],
338
+ 'default_model': 'whisper',
339
+ 'chunk_length_s': 45,
340
+ 'batch_size': 32,
341
+ 'enable_vad': False
342
+ }
343
+
344
+ # Should not raise exception
345
+ service._validate_stt_updates(updates)
346
+
347
+ def test_validate_stt_updates_invalid_provider(self, service):
348
+ """Test STT updates validation with invalid provider"""
349
+ updates = {
350
+ 'preferred_providers': ['invalid_stt']
351
+ }
352
+
353
+ with pytest.raises(ConfigurationException, match="Invalid STT provider"):
354
+ service._validate_stt_updates(updates)
355
+
356
+ def test_validate_translation_updates_valid(self, service):
357
+ """Test translation updates validation with valid data"""
358
+ updates = {
359
+ 'default_provider': 'nllb',
360
+ 'model_name': 'nllb-200-1.3B',
361
+ 'max_chunk_length': 256,
362
+ 'batch_size': 4,
363
+ 'cache_translations': False
364
+ }
365
+
366
+ # Should not raise exception
367
+ service._validate_translation_updates(updates)
368
+
369
+ def test_validate_processing_updates_valid(self, service):
370
+ """Test processing updates validation with valid data"""
371
+ updates = {
372
+ 'temp_dir': '/tmp/test',
373
+ 'cleanup_temp_files': True,
374
+ 'max_file_size_mb': 150,
375
+ 'supported_audio_formats': ['wav', 'mp3'],
376
+ 'processing_timeout_seconds': 600
377
+ }
378
+
379
+ with patch('pathlib.Path.mkdir'):
380
+ # Should not raise exception
381
+ service._validate_processing_updates(updates)
382
+
383
+ def test_validate_processing_updates_invalid_format(self, service):
384
+ """Test processing updates validation with invalid audio format"""
385
+ updates = {
386
+ 'supported_audio_formats': ['wav', 'invalid_format']
387
+ }
388
+
389
+ with pytest.raises(ConfigurationException, match="Invalid audio format"):
390
+ service._validate_processing_updates(updates)
391
+
392
+ def test_save_configuration_to_file_success(self, service, mock_config):
393
+ """Test successful configuration save to file"""
394
+ file_path = "/tmp/config.json"
395
+
396
+ service.save_configuration_to_file(file_path)
397
+
398
+ mock_config.save_configuration.assert_called_once_with(file_path)
399
+
400
+ def test_save_configuration_to_file_failure(self, service, mock_config):
401
+ """Test configuration save to file failure"""
402
+ file_path = "/tmp/config.json"
403
+ mock_config.save_configuration.side_effect = Exception("Save failed")
404
+
405
+ with pytest.raises(ConfigurationException, match="Failed to save configuration"):
406
+ service.save_configuration_to_file(file_path)
407
+
408
+ @patch('os.path.exists')
409
+ def test_load_configuration_from_file_success(self, mock_exists, service, mock_container):
410
+ """Test successful configuration load from file"""
411
+ file_path = "/tmp/config.json"
412
+ mock_exists.return_value = True
413
+
414
+ with patch('src.infrastructure.config.app_config.AppConfig') as mock_app_config:
415
+ new_config = Mock()
416
+ mock_app_config.return_value = new_config
417
+
418
+ result = service.load_configuration_from_file(file_path)
419
+
420
+ # Verify new config was created and registered
421
+ mock_app_config.assert_called_once_with(config_file=file_path)
422
+ mock_container.register_singleton.assert_called_once_with(AppConfig, new_config)
423
+
424
+ @patch('os.path.exists')
425
+ def test_load_configuration_from_file_not_found(self, mock_exists, service):
426
+ """Test configuration load from non-existent file"""
427
+ file_path = "/tmp/nonexistent.json"
428
+ mock_exists.return_value = False
429
+
430
+ with pytest.raises(ConfigurationException, match="Configuration file not found"):
431
+ service.load_configuration_from_file(file_path)
432
+
433
+ def test_reload_configuration_success(self, service, mock_config):
434
+ """Test successful configuration reload"""
435
+ result = service.reload_configuration()
436
+
437
+ mock_config.reload_configuration.assert_called_once()
438
+ assert 'tts' in result
439
+
440
+ def test_reload_configuration_failure(self, service, mock_config):
441
+ """Test configuration reload failure"""
442
+ mock_config.reload_configuration.side_effect = Exception("Reload failed")
443
+
444
+ with pytest.raises(ConfigurationException, match="Failed to reload configuration"):
445
+ service.reload_configuration()
446
+
447
+ def test_get_provider_availability(self, service, mock_container):
448
+ """Test provider availability check"""
449
+ # Mock factories
450
+ mock_tts_factory = Mock()
451
+ mock_stt_factory = Mock()
452
+ mock_translation_factory = Mock()
453
+
454
+ mock_container.resolve.side_effect = [mock_tts_factory, mock_stt_factory, mock_translation_factory]
455
+
456
+ # Mock successful provider creation
457
+ mock_tts_factory.create_provider.return_value = Mock()
458
+ mock_stt_factory.create_provider.return_value = Mock()
459
+ mock_translation_factory.get_default_provider.return_value = Mock()
460
+
461
+ result = service.get_provider_availability()
462
+
463
+ assert 'tts' in result
464
+ assert 'stt' in result
465
+ assert 'translation' in result
466
+
467
+ # All providers should be available
468
+ assert all(result['tts'].values())
469
+ assert all(result['stt'].values())
470
+ assert result['translation']['nllb'] is True
471
+
472
+ def test_get_system_info(self, service, mock_config):
473
+ """Test system information retrieval"""
474
+ mock_config.config_file = "/tmp/config.json"
475
+ mock_config.processing.temp_dir = "/tmp"
476
+ mock_config.logging.level = "INFO"
477
+ mock_config.processing.supported_audio_formats = ['wav', 'mp3']
478
+ mock_config.processing.max_file_size_mb = 100
479
+ mock_config.processing.processing_timeout_seconds = 300
480
+
481
+ with patch.object(service, 'get_provider_availability', return_value={}):
482
+ result = service.get_system_info()
483
+
484
+ assert result['config_file'] == "/tmp/config.json"
485
+ assert result['temp_directory'] == "/tmp"
486
+ assert result['log_level'] == "INFO"
487
+ assert 'supported_languages' in result
488
+ assert 'supported_audio_formats' in result
489
+ assert 'max_file_size_mb' in result
490
+
491
+ def test_validate_configuration_success(self, service, mock_config):
492
+ """Test successful configuration validation"""
493
+ # Mock valid configuration
494
+ mock_config.get_tts_config.return_value = {
495
+ 'default_speed': 1.0,
496
+ 'max_text_length': 5000
497
+ }
498
+ mock_config.get_stt_config.return_value = {
499
+ 'chunk_length_s': 30,
500
+ 'batch_size': 16
501
+ }
502
+ mock_config.get_processing_config.return_value = {
503
+ 'temp_dir': '/tmp',
504
+ 'max_file_size_mb': 100
505
+ }
506
+ mock_config.get_logging_config.return_value = {
507
+ 'level': 'INFO'
508
+ }
509
+
510
+ with patch('os.path.exists', return_value=True):
511
+ result = service.validate_configuration()
512
+
513
+ # Should have no issues
514
+ assert all(len(issues) == 0 for issues in result.values())
515
+
516
+ def test_validate_configuration_with_issues(self, service, mock_config):
517
+ """Test configuration validation with issues"""
518
+ # Mock invalid configuration
519
+ mock_config.get_tts_config.return_value = {
520
+ 'default_speed': 5.0, # Invalid
521
+ 'max_text_length': -100 # Invalid
522
+ }
523
+ mock_config.get_stt_config.return_value = {
524
+ 'chunk_length_s': -10, # Invalid
525
+ 'batch_size': 0 # Invalid
526
+ }
527
+ mock_config.get_processing_config.return_value = {
528
+ 'temp_dir': '/nonexistent', # Invalid
529
+ 'max_file_size_mb': -50 # Invalid
530
+ }
531
+ mock_config.get_logging_config.return_value = {
532
+ 'level': 'INVALID' # Invalid
533
+ }
534
+
535
+ with patch('os.path.exists', return_value=False):
536
+ result = service.validate_configuration()
537
+
538
+ # Should have issues in each category
539
+ assert len(result['tts']) > 0
540
+ assert len(result['stt']) > 0
541
+ assert len(result['processing']) > 0
542
+ assert len(result['logging']) > 0
543
+
544
+ def test_reset_to_defaults_success(self, service, mock_container):
545
+ """Test successful configuration reset to defaults"""
546
+ with patch('src.infrastructure.config.app_config.AppConfig') as mock_app_config:
547
+ default_config = Mock()
548
+ mock_app_config.return_value = default_config
549
+
550
+ result = service.reset_to_defaults()
551
+
552
+ # Verify new default config was created and registered
553
+ mock_app_config.assert_called_once_with()
554
+ mock_container.register_singleton.assert_called_once_with(AppConfig, default_config)
555
+
556
+ def test_reset_to_defaults_failure(self, service):
557
+ """Test configuration reset to defaults failure"""
558
+ with patch('src.infrastructure.config.app_config.AppConfig', side_effect=Exception("Reset failed")):
559
+ with pytest.raises(ConfigurationException, match="Failed to reset configuration"):
560
+ service.reset_to_defaults()
561
+
562
+ def test_cleanup(self, service):
563
+ """Test service cleanup"""
564
+ # Should not raise exception
565
+ service.cleanup()
566
+
567
+ def test_context_manager(self, service):
568
+ """Test service as context manager"""
569
+ with patch.object(service, 'cleanup') as mock_cleanup:
570
+ with service as svc:
571
+ assert svc == service
572
+ mock_cleanup.assert_called_once()