youssefreda9 commited on
Commit
e8d6977
·
1 Parent(s): 7feaacd

test: 36 unit tests for dialect feature (validation, conversion, errors, service, health, security)

Browse files
Files changed (1) hide show
  1. 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