Commit ·
e8d6977
1
Parent(s): 7feaacd
test: 36 unit tests for dialect feature (validation, conversion, errors, service, health, security)
Browse files- test_dialect.py +301 -0
test_dialect.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for the Dialect-to-MSA feature.
|
| 3 |
+
Tests Flask route validation, service layer, and edge cases.
|
| 4 |
+
All tests mock the model so NO actual model loading happens.
|
| 5 |
+
|
| 6 |
+
Run: python -m pytest test_dialect.py -v
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import sys
|
| 10 |
+
import os
|
| 11 |
+
import pytest
|
| 12 |
+
from unittest.mock import patch, MagicMock
|
| 13 |
+
|
| 14 |
+
# Add src to path
|
| 15 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
| 16 |
+
os.chdir(os.path.join(os.path.dirname(__file__), 'src'))
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ═══════════════════════════════════════════════════════════════
|
| 20 |
+
# FIXTURE: Flask test client (no model loading)
|
| 21 |
+
# ═══════════════════════════════════════════════════════════════
|
| 22 |
+
|
| 23 |
+
@pytest.fixture
|
| 24 |
+
def client():
|
| 25 |
+
"""Create Flask test client without loading any models."""
|
| 26 |
+
with patch('app.load_models', return_value=True):
|
| 27 |
+
from app import app
|
| 28 |
+
app.config['TESTING'] = True
|
| 29 |
+
with app.test_client() as c:
|
| 30 |
+
yield c
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _mock_dialect():
|
| 34 |
+
"""Create a mock DialectConverter that returns predictable output."""
|
| 35 |
+
mock = MagicMock()
|
| 36 |
+
mock.convert.side_effect = lambda text, **kw: f"[MSA] {text}"
|
| 37 |
+
mock.is_ready.return_value = True
|
| 38 |
+
return mock
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@pytest.fixture
|
| 42 |
+
def mock_model():
|
| 43 |
+
"""Patch dialect model globally so no real model loading happens."""
|
| 44 |
+
m = _mock_dialect()
|
| 45 |
+
with patch('nlp.dialect.dialect_service._instance', m):
|
| 46 |
+
with patch('nlp.dialect.dialect_service.get_dialect_model', return_value=m):
|
| 47 |
+
yield m
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ═══════════════════════════════════════════════════════════════
|
| 51 |
+
# GROUP 1: Input Validation (no model needed)
|
| 52 |
+
# ═══════════════════════════════════════════════════════════════
|
| 53 |
+
|
| 54 |
+
class TestDialectInputValidation:
|
| 55 |
+
"""Tests that verify input validation BEFORE the model is called."""
|
| 56 |
+
|
| 57 |
+
def test_empty_text_returns_400(self, client):
|
| 58 |
+
r = client.post('/api/dialect', json={'text': ''})
|
| 59 |
+
assert r.status_code == 400
|
| 60 |
+
assert r.get_json()['status'] == 'error'
|
| 61 |
+
|
| 62 |
+
def test_whitespace_only_returns_400(self, client):
|
| 63 |
+
r = client.post('/api/dialect', json={'text': ' '})
|
| 64 |
+
assert r.status_code == 400
|
| 65 |
+
assert r.get_json()['status'] == 'error'
|
| 66 |
+
|
| 67 |
+
def test_missing_text_field_returns_400(self, client):
|
| 68 |
+
r = client.post('/api/dialect', json={})
|
| 69 |
+
assert r.status_code == 400
|
| 70 |
+
|
| 71 |
+
def test_text_too_long_returns_400(self, client):
|
| 72 |
+
r = client.post('/api/dialect', json={'text': 'ا' * 5001})
|
| 73 |
+
assert r.status_code == 400
|
| 74 |
+
assert '5000' in r.get_json()['error']
|
| 75 |
+
|
| 76 |
+
def test_non_json_returns_400(self, client):
|
| 77 |
+
r = client.post('/api/dialect', data='plain text',
|
| 78 |
+
content_type='text/plain')
|
| 79 |
+
assert r.status_code == 400
|
| 80 |
+
|
| 81 |
+
def test_get_method_not_allowed(self, client):
|
| 82 |
+
r = client.get('/api/dialect')
|
| 83 |
+
assert r.status_code in [404, 405] # Flask static_folder returns 404
|
| 84 |
+
|
| 85 |
+
def test_put_method_not_allowed(self, client):
|
| 86 |
+
r = client.put('/api/dialect', json={'text': 'test'})
|
| 87 |
+
assert r.status_code == 405
|
| 88 |
+
|
| 89 |
+
def test_null_text_handled(self, client):
|
| 90 |
+
r = client.post('/api/dialect', json={'text': None})
|
| 91 |
+
assert r.status_code in [400, 500]
|
| 92 |
+
assert r.get_json()['status'] == 'error'
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# ═══════════════════════════════════════════════════════════════
|
| 96 |
+
# GROUP 2: Successful Conversion (mocked model)
|
| 97 |
+
# ═══════════════════════════════════════════════════════════════
|
| 98 |
+
|
| 99 |
+
class TestDialectConversion:
|
| 100 |
+
"""Tests with mocked model to verify route logic."""
|
| 101 |
+
|
| 102 |
+
def test_success_response_format(self, client, mock_model):
|
| 103 |
+
r = client.post('/api/dialect', json={'text': 'عايز اروح البيت'})
|
| 104 |
+
assert r.status_code == 200
|
| 105 |
+
data = r.get_json()
|
| 106 |
+
assert data['status'] == 'success'
|
| 107 |
+
assert 'original_text' in data
|
| 108 |
+
assert 'converted_text' in data
|
| 109 |
+
|
| 110 |
+
def test_original_text_preserved(self, client, mock_model):
|
| 111 |
+
r = client.post('/api/dialect', json={'text': 'عايز اروح البيت'})
|
| 112 |
+
data = r.get_json()
|
| 113 |
+
assert data['original_text'] == 'عايز اروح البيت'
|
| 114 |
+
|
| 115 |
+
def test_converted_text_from_model(self, client, mock_model):
|
| 116 |
+
r = client.post('/api/dialect', json={'text': 'عايز اروح البيت'})
|
| 117 |
+
data = r.get_json()
|
| 118 |
+
assert data['converted_text'] == '[MSA] عايز اروح البيت'
|
| 119 |
+
|
| 120 |
+
def test_text_gets_stripped(self, client, mock_model):
|
| 121 |
+
r = client.post('/api/dialect', json={'text': ' عايز اروح '})
|
| 122 |
+
data = r.get_json()
|
| 123 |
+
assert data['original_text'] == 'عايز اروح'
|
| 124 |
+
|
| 125 |
+
def test_text_exactly_5000_passes(self, client, mock_model):
|
| 126 |
+
text = 'ا' * 5000
|
| 127 |
+
r = client.post('/api/dialect', json={'text': text})
|
| 128 |
+
assert r.status_code == 200
|
| 129 |
+
|
| 130 |
+
def test_special_chars_dont_crash(self, client, mock_model):
|
| 131 |
+
r = client.post('/api/dialect',
|
| 132 |
+
json={'text': '<script>alert(1)</script>'})
|
| 133 |
+
assert r.status_code == 200
|
| 134 |
+
|
| 135 |
+
def test_sql_injection_safe(self, client, mock_model):
|
| 136 |
+
r = client.post('/api/dialect',
|
| 137 |
+
json={'text': "'; DROP TABLE users; --"})
|
| 138 |
+
assert r.status_code == 200
|
| 139 |
+
|
| 140 |
+
def test_unicode_bidi_safe(self, client, mock_model):
|
| 141 |
+
r = client.post('/api/dialect',
|
| 142 |
+
json={'text': '\u200e\u200f\u202a عايز'})
|
| 143 |
+
assert r.status_code == 200
|
| 144 |
+
|
| 145 |
+
def test_emoji_safe(self, client, mock_model):
|
| 146 |
+
r = client.post('/api/dialect',
|
| 147 |
+
json={'text': 'عايز اروح 🏠 دلوقتي'})
|
| 148 |
+
assert r.status_code == 200
|
| 149 |
+
|
| 150 |
+
def test_newlines_safe(self, client, mock_model):
|
| 151 |
+
r = client.post('/api/dialect',
|
| 152 |
+
json={'text': 'عايز\nاروح\nالبيت'})
|
| 153 |
+
assert r.status_code == 200
|
| 154 |
+
|
| 155 |
+
def test_numbers_safe(self, client, mock_model):
|
| 156 |
+
r = client.post('/api/dialect',
|
| 157 |
+
json={'text': 'عايز 5 كيلو و 3 كيلو'})
|
| 158 |
+
assert r.status_code == 200
|
| 159 |
+
|
| 160 |
+
def test_english_mixed_safe(self, client, mock_model):
|
| 161 |
+
r = client.post('/api/dialect',
|
| 162 |
+
json={'text': 'عايز اعمل login في ال app'})
|
| 163 |
+
assert r.status_code == 200
|
| 164 |
+
|
| 165 |
+
def test_extra_json_fields_ignored(self, client, mock_model):
|
| 166 |
+
r = client.post('/api/dialect',
|
| 167 |
+
json={'text': 'عايز', 'extra': 'field', 'hack': True})
|
| 168 |
+
assert r.status_code == 200
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ═══════════════════════════════════════════════════════════════
|
| 172 |
+
# GROUP 3: Model Error Handling
|
| 173 |
+
# ═══════════════════════════════════════════════════════════════
|
| 174 |
+
|
| 175 |
+
class TestDialectErrorHandling:
|
| 176 |
+
"""Tests for when the model throws errors."""
|
| 177 |
+
|
| 178 |
+
def test_runtime_error_returns_503(self, client):
|
| 179 |
+
mock = MagicMock()
|
| 180 |
+
mock.convert.side_effect = RuntimeError("Model OOM")
|
| 181 |
+
with patch('nlp.dialect.dialect_service._instance', mock):
|
| 182 |
+
with patch('nlp.dialect.dialect_service.get_dialect_model', return_value=mock):
|
| 183 |
+
r = client.post('/api/dialect', json={'text': 'عايز'})
|
| 184 |
+
assert r.status_code == 503
|
| 185 |
+
assert r.get_json()['status'] == 'error'
|
| 186 |
+
|
| 187 |
+
def test_generic_exception_returns_500(self, client):
|
| 188 |
+
mock = MagicMock()
|
| 189 |
+
mock.convert.side_effect = ValueError("Bad tensor")
|
| 190 |
+
with patch('nlp.dialect.dialect_service._instance', mock):
|
| 191 |
+
with patch('nlp.dialect.dialect_service.get_dialect_model', return_value=mock):
|
| 192 |
+
r = client.post('/api/dialect', json={'text': 'عايز'})
|
| 193 |
+
assert r.status_code == 500
|
| 194 |
+
assert r.get_json()['status'] == 'error'
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# ═══════════════════════════════════════════════════════════════
|
| 198 |
+
# GROUP 4: DialectConverter Service Unit Tests
|
| 199 |
+
# ═══════════════════════════════════════════════════════════════
|
| 200 |
+
|
| 201 |
+
class TestDialectService:
|
| 202 |
+
"""Tests for the DialectConverter class directly."""
|
| 203 |
+
|
| 204 |
+
def test_singleton_pattern(self):
|
| 205 |
+
from nlp.dialect import dialect_service
|
| 206 |
+
dialect_service._instance = None
|
| 207 |
+
with patch.object(dialect_service.DialectConverter, '__init__', return_value=None):
|
| 208 |
+
with patch.object(dialect_service.DialectConverter, 'is_ready', return_value=True):
|
| 209 |
+
m1 = dialect_service.get_dialect_model()
|
| 210 |
+
m2 = dialect_service.get_dialect_model()
|
| 211 |
+
assert m1 is m2
|
| 212 |
+
dialect_service._instance = None
|
| 213 |
+
|
| 214 |
+
def test_is_loaded_false_initially(self):
|
| 215 |
+
from nlp.dialect import dialect_service
|
| 216 |
+
dialect_service._instance = None
|
| 217 |
+
assert dialect_service.is_loaded() is False
|
| 218 |
+
|
| 219 |
+
def test_is_loaded_true_after_init(self):
|
| 220 |
+
from nlp.dialect import dialect_service
|
| 221 |
+
mock = MagicMock()
|
| 222 |
+
mock.is_ready.return_value = True
|
| 223 |
+
dialect_service._instance = mock
|
| 224 |
+
assert dialect_service.is_loaded() is True
|
| 225 |
+
dialect_service._instance = None
|
| 226 |
+
|
| 227 |
+
def test_convert_empty_passthrough(self):
|
| 228 |
+
from nlp.dialect.dialect_service import DialectConverter
|
| 229 |
+
c = DialectConverter.__new__(DialectConverter)
|
| 230 |
+
c.model = MagicMock()
|
| 231 |
+
c.tokenizer = MagicMock()
|
| 232 |
+
c.device = "cpu"
|
| 233 |
+
assert c.convert("") == ""
|
| 234 |
+
c.model.generate.assert_not_called()
|
| 235 |
+
|
| 236 |
+
def test_convert_whitespace_passthrough(self):
|
| 237 |
+
from nlp.dialect.dialect_service import DialectConverter
|
| 238 |
+
c = DialectConverter.__new__(DialectConverter)
|
| 239 |
+
c.model = MagicMock()
|
| 240 |
+
c.tokenizer = MagicMock()
|
| 241 |
+
c.device = "cpu"
|
| 242 |
+
assert c.convert(" ") == " "
|
| 243 |
+
c.model.generate.assert_not_called()
|
| 244 |
+
|
| 245 |
+
def test_convert_none_passthrough(self):
|
| 246 |
+
from nlp.dialect.dialect_service import DialectConverter
|
| 247 |
+
c = DialectConverter.__new__(DialectConverter)
|
| 248 |
+
c.model = MagicMock()
|
| 249 |
+
c.tokenizer = MagicMock()
|
| 250 |
+
c.device = "cpu"
|
| 251 |
+
assert c.convert(None) is None
|
| 252 |
+
c.model.generate.assert_not_called()
|
| 253 |
+
|
| 254 |
+
def test_prefix_correct(self):
|
| 255 |
+
from nlp.dialect.dialect_service import DialectConverter
|
| 256 |
+
assert DialectConverter.PREFIX == "حوّل إلى الفصحى: "
|
| 257 |
+
|
| 258 |
+
def test_repo_id_correct(self):
|
| 259 |
+
from nlp.dialect.dialect_service import DialectConverter
|
| 260 |
+
assert DialectConverter.REPO_ID == "bayan10/dialect-to-msa-model"
|
| 261 |
+
|
| 262 |
+
def test_max_lengths(self):
|
| 263 |
+
from nlp.dialect.dialect_service import DialectConverter
|
| 264 |
+
assert DialectConverter.MAX_INPUT_LENGTH == 128
|
| 265 |
+
assert DialectConverter.MAX_OUTPUT_LENGTH == 128
|
| 266 |
+
|
| 267 |
+
def test_is_ready_both_required(self):
|
| 268 |
+
from nlp.dialect.dialect_service import DialectConverter
|
| 269 |
+
c = DialectConverter.__new__(DialectConverter)
|
| 270 |
+
c.model = None; c.tokenizer = MagicMock()
|
| 271 |
+
assert c.is_ready() is False
|
| 272 |
+
c.model = MagicMock(); c.tokenizer = None
|
| 273 |
+
assert c.is_ready() is False
|
| 274 |
+
c.model = MagicMock(); c.tokenizer = MagicMock()
|
| 275 |
+
assert c.is_ready() is True
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
# ═══════════════════════════════════════════════════════════════
|
| 279 |
+
# GROUP 5: Health Check Integration
|
| 280 |
+
# ═══════════════════════════════════════════════════════════════
|
| 281 |
+
|
| 282 |
+
class TestDialectHealthCheck:
|
| 283 |
+
|
| 284 |
+
def test_health_includes_dialect(self, client):
|
| 285 |
+
r = client.get('/api/health')
|
| 286 |
+
data = r.get_json()
|
| 287 |
+
assert 'dialect' in data['models']
|
| 288 |
+
|
| 289 |
+
def test_dialect_false_when_not_loaded(self, client):
|
| 290 |
+
from nlp.dialect import dialect_service
|
| 291 |
+
dialect_service._instance = None
|
| 292 |
+
r = client.get('/api/health')
|
| 293 |
+
assert r.get_json()['models']['dialect'] is False
|
| 294 |
+
|
| 295 |
+
def test_dialect_true_when_loaded(self, client):
|
| 296 |
+
from nlp.dialect import dialect_service
|
| 297 |
+
mock = MagicMock(); mock.is_ready.return_value = True
|
| 298 |
+
dialect_service._instance = mock
|
| 299 |
+
r = client.get('/api/health')
|
| 300 |
+
assert r.get_json()['models']['dialect'] is True
|
| 301 |
+
dialect_service._instance = None
|