Spaces:
Sleeping
Sleeping
Michael Hu
commited on
Commit
·
acd758a
1
Parent(s):
48f8a08
Create unit tests for application layer
Browse files- tests/unit/application/__init__.py +1 -0
- tests/unit/application/dtos/__init__.py +1 -0
- tests/unit/application/dtos/test_audio_upload_dto.py +245 -0
- tests/unit/application/dtos/test_dto_validation.py +319 -0
- tests/unit/application/dtos/test_processing_request_dto.py +383 -0
- tests/unit/application/dtos/test_processing_result_dto.py +436 -0
- tests/unit/application/services/__init__.py +1 -0
- tests/unit/application/services/test_audio_processing_service.py +475 -0
- tests/unit/application/services/test_configuration_service.py +572 -0
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()
|