1
+ import uuid
2
+ from unittest .mock import MagicMock , patch
3
+ import pytest
4
+
5
+ from app import crud
6
+ from app .models .exceptions import QuestionNotFoundException
7
+
8
+ # --- Mocks Setup ---
9
+
10
+ @pytest .fixture
11
+ def mock_db_conn ():
12
+ """Fixture to mock the database connection and cursor."""
13
+ with patch ('app.crud.get_conn' ) as mock_get_conn :
14
+ mock_conn = MagicMock ()
15
+ mock_cursor = MagicMock ()
16
+ mock_cursor .connection = mock_conn
17
+ mock_get_conn .return_value .__enter__ .return_value = mock_conn
18
+ mock_conn .cursor .return_value .__enter__ .return_value = mock_cursor
19
+ yield mock_cursor
20
+
21
+ @pytest .fixture
22
+ def mock_s3 ():
23
+ """Fixture to mock all S3 utility functions."""
24
+ with patch ('app.crud.upload_to_s3' ) as mock_upload , \
25
+ patch ('app.crud.get_from_s3' ) as mock_get , \
26
+ patch ('app.crud.delete_from_s3' ) as mock_delete :
27
+ yield {
28
+ "upload" : mock_upload ,
29
+ "get" : mock_get ,
30
+ "delete" : mock_delete
31
+ }
32
+
33
+ # --- Additional Fixtures for Override Tests ---
34
+
35
+ @pytest .fixture
36
+ def mock_question_funcs ():
37
+ """Mock all external question functions"""
38
+ with patch ('app.crud.get_question' ) as mock_get , \
39
+ patch ('app.crud.delete_question' ) as mock_delete , \
40
+ patch ('app.crud.create_question' ) as mock_create :
41
+ yield mock_get , mock_delete , mock_create
42
+
43
+
44
+ @pytest .fixture
45
+ def backup_data ():
46
+ """Sample backup question data"""
47
+ return {
48
+ "name" : "Old Question" ,
49
+ "description" : "Old description" ,
50
+ "difficulty" : "Easy" ,
51
+ "topic" : "Math" ,
52
+ "images" : [b"old_image" ]
53
+ }
54
+
55
+ # --- Test Cases ---
56
+
57
+ def test_list_difficulties_and_topics (mock_db_conn ):
58
+ """
59
+ Tests that `list_difficulties_and_topics` correctly fetches and formats data.
60
+ """
61
+ # Arrange
62
+ mock_db_conn .fetchall .side_effect = [
63
+ [('Easy' ,), ('Hard' ,)], # Difficulties
64
+ [('Math' ,), ('Science' ,)] # Topics
65
+ ]
66
+
67
+ # Act
68
+ result = crud .list_difficulties_and_topics ()
69
+
70
+ # Assert
71
+ assert result == {
72
+ "difficulties" : ["Easy" , "Hard" ],
73
+ "topics" : ["Math" , "Science" ]
74
+ }
75
+ assert mock_db_conn .execute .call_count == 2
76
+ mock_db_conn .execute .assert_any_call ("SELECT * FROM difficulties" )
77
+ mock_db_conn .execute .assert_any_call ("SELECT * FROM topics" )
78
+
79
+
80
+ def test_list_difficulties_and_topics_empty (mock_db_conn ):
81
+ """
82
+ Tests that `list_difficulties_and_topics` correctly fetches and formats data.
83
+ """
84
+ # Arrange
85
+ mock_db_conn .fetchall .side_effect = [
86
+ [], # Difficulties
87
+ [] # Topics
88
+ ]
89
+
90
+ # Act
91
+ result = crud .list_difficulties_and_topics ()
92
+
93
+ # Assert
94
+ assert result == {
95
+ "difficulties" : [],
96
+ "topics" : []
97
+ }
98
+ assert mock_db_conn .execute .call_count == 2
99
+ mock_db_conn .execute .assert_any_call ("SELECT * FROM difficulties" )
100
+ mock_db_conn .execute .assert_any_call ("SELECT * FROM topics" )
101
+
102
+
103
+ def test_create_question_success_without_images (mock_db_conn , mock_s3 ):
104
+ """
105
+ Tests successful question creation with images, verifying S3 upload and DB inserts.
106
+ """
107
+ # Act
108
+ result_qid = crud .create_question ("Test Q" , "Desc" , "Easy" , "Math" )
109
+
110
+ # Assert
111
+ mock_s3 ["upload" ].assert_not_called ()
112
+ assert isinstance (result_qid , str ) and len (result_qid ) > 0
113
+
114
+ # Check DB calls
115
+ assert mock_db_conn .execute .call_count == 1 # Only question uploaded
116
+ mock_s3 ["delete" ].assert_not_called ()
117
+
118
+
119
+ def test_create_question_success_with_images (mock_db_conn , mock_s3 ):
120
+ """
121
+ Tests successful question creation with images, verifying S3 upload and DB inserts.
122
+ """
123
+ # Arrange
124
+ qid = str (uuid .uuid4 ())
125
+ images = [b'img1_data' , b'img2_data' ]
126
+ expected_keys = [(images [0 ], f"questions/{ qid } /0" ), (images [1 ], f"questions/{ qid } /1" )]
127
+
128
+ # Act
129
+ result_qid = crud .create_question ("Test Q" , "Desc" , "Easy" , "Math" , images , qid )
130
+
131
+ # Assert
132
+ assert result_qid == qid
133
+ mock_s3 ["upload" ].assert_called_once_with (expected_keys )
134
+
135
+ # Check DB calls
136
+ assert mock_db_conn .execute .call_count == 3 # 1 for question, 2 for images
137
+ mock_s3 ["delete" ].assert_not_called ()
138
+
139
+
140
+ def test_create_question_db_fails_with_cleanup (mock_db_conn , mock_s3 ):
141
+ """
142
+ Tests that if DB insertion fails, uploaded S3 images are deleted.
143
+ """
144
+ # Arrange
145
+ qid = str (uuid .uuid4 ())
146
+ images = [b'img1_data' ]
147
+ expected_keys = [(images [0 ], f"questions/{ qid } /0" )]
148
+ mock_db_conn .execute .side_effect = Exception ("DB Error" )
149
+
150
+ # Act & Assert
151
+ with pytest .raises (Exception , match = "DB Error" ):
152
+ crud .create_question ("Test Q" , "Desc" , "Easy" , "Math" , images , qid )
153
+
154
+ mock_s3 ["upload" ].assert_called_once_with (expected_keys )
155
+ mock_s3 ["delete" ].assert_called_once_with ([key for _ , key in expected_keys ])
156
+
157
+ def test_get_question_success (mock_db_conn , mock_s3 ):
158
+ """
159
+ Tests successful retrieval of a question and its images.
160
+ """
161
+ # Arrange
162
+ qid = str (uuid .uuid4 ())
163
+ question_data = ('Test Q' , 'Desc' , 'Easy' , 'Math' )
164
+ image_keys = [(f'questions/{ qid } /key1' ,), (f'questions/{ qid } /key2' ,)]
165
+ image_data = [b'img1' , b'img2' ]
166
+
167
+ mock_db_conn .fetchone .return_value = question_data
168
+ mock_db_conn .fetchall .return_value = image_keys
169
+ mock_s3 ["get" ].return_value = image_data
170
+
171
+ # Act
172
+ result = crud .get_question (qid )
173
+
174
+ # Assert
175
+ assert result == {
176
+ "id" : qid ,
177
+ "name" : 'Test Q' ,
178
+ "description" : 'Desc' ,
179
+ "difficulty" : 'Easy' ,
180
+ "topic" : 'Math' ,
181
+ "images" : image_data
182
+ }
183
+ mock_s3 ["get" ].assert_called_once_with ([f'questions/{ qid } /key1' , f'questions/{ qid } /key2' ])
184
+
185
+ def test_get_question_not_found (mock_db_conn ):
186
+ """
187
+ Tests that `QuestionNotFoundException` is raised for a non-existent question.
188
+ """
189
+ # Arrange
190
+ mock_db_conn .fetchone .return_value = None
191
+ qid = str (uuid .uuid4 ())
192
+
193
+ # Act & Assert
194
+ with pytest .raises (QuestionNotFoundException ):
195
+ crud .get_question (qid )
196
+
197
+ def test_get_random_question_success (mock_db_conn , mock_s3 ):
198
+ """
199
+ Tests successful retrieval of a random question.
200
+ """
201
+ # Arrange
202
+ qid = str (uuid .uuid4 ())
203
+ row_data = (qid , 'Random Q' , 'Desc' , 'Hard' , 'Science' )
204
+ mock_db_conn .fetchone .return_value = row_data
205
+ mock_s3 ["get" ].return_value = [] # No images
206
+
207
+ # Act
208
+ result = crud .get_random_question_by_difficulty_and_topic ('Hard' , 'Science' )
209
+
210
+ # Assert
211
+ assert result == {
212
+ "id" : qid ,
213
+ "name" : 'Random Q' ,
214
+ "description" : 'Desc' ,
215
+ "difficulty" : 'Hard' ,
216
+ "topic" : 'Science' ,
217
+ "images" : [] # No images
218
+ }
219
+
220
+ def test_get_random_question_not_found (mock_db_conn ):
221
+ """
222
+ Tests that `QuestionNotFoundException` is raised if no question matches criteria.
223
+ """
224
+ # Arrange
225
+ mock_db_conn .fetchone .return_value = None
226
+
227
+ # Act & Assert
228
+ with pytest .raises (QuestionNotFoundException ):
229
+ crud .get_random_question_by_difficulty_and_topic ('Impossible' , 'Metaphysics' )
230
+
231
+ def test_delete_question_success (mock_db_conn , mock_s3 ):
232
+ """
233
+ Tests successful deletion of a question and its S3 images.
234
+ """
235
+ # Arrange
236
+ qid = str (uuid .uuid4 ())
237
+ image_keys = [('questions/key1' ,)]
238
+ mock_db_conn .fetchall .return_value = image_keys
239
+ mock_db_conn .fetchone .return_value = (qid ,) # Mock RETURNING id
240
+
241
+ # Act
242
+ crud .delete_question (qid )
243
+
244
+ # Assert
245
+ mock_db_conn .execute .assert_any_call ("DELETE FROM questions WHERE id = %s RETURNING id" , (qid ,))
246
+ mock_s3 ["delete" ].assert_called_once_with (['questions/key1' ])
247
+ mock_db_conn .connection .rollback .assert_not_called ()
248
+
249
+ def test_delete_question_not_found (mock_db_conn ):
250
+ """
251
+ Tests that QuestionNotFoundException is raised for non-existent question.
252
+ """
253
+ # Arrange
254
+ qid = str (uuid .uuid4 ())
255
+ mock_db_conn .fetchone .return_value = None # No question found
256
+
257
+ # Act & Assert
258
+ with pytest .raises (QuestionNotFoundException ):
259
+ crud .delete_question (qid )
260
+
261
+ def test_delete_question_s3_fails_rolls_back (mock_db_conn , mock_s3 ):
262
+ """
263
+ Tests that the DB transaction is rolled back if S3 deletion fails.
264
+ """
265
+ # Arrange
266
+ qid = str (uuid .uuid4 ())
267
+ mock_db_conn .fetchall .return_value = [('key1' ,)]
268
+ mock_db_conn .fetchone .return_value = (qid ,)
269
+ mock_s3 ["delete" ].side_effect = Exception ("S3 Error" )
270
+
271
+ # Get the actual connection object to check rollback on it
272
+ mock_conn = mock_db_conn .connection
273
+
274
+ # Act & Assert
275
+ with pytest .raises (Exception , match = "S3 Error" ):
276
+ crud .delete_question (qid )
277
+
278
+ # The connection object is accessed via the cursor's parent
279
+ mock_conn .rollback .assert_called_once ()
280
+
281
+ def test_successful_override (mock_question_funcs , backup_data ):
282
+ """Test successful question override"""
283
+ mock_get , mock_delete , mock_create = mock_question_funcs
284
+ mock_get .return_value = backup_data
285
+
286
+ result = crud .override_question ("qid123" , "New" , "New desc" , "Hard" , "Science" , [b"img" ])
287
+
288
+ assert result == "qid123"
289
+ mock_get .assert_called_once_with ("qid123" )
290
+ mock_delete .assert_called_once_with ("qid123" )
291
+ mock_create .assert_called_once_with ("New" , "New desc" , "Hard" , "Science" , [b"img" ], "qid123" )
292
+
293
+
294
+ def test_override_without_images (mock_question_funcs , backup_data ):
295
+ """Test override without providing images"""
296
+ mock_get , mock_delete , mock_create = mock_question_funcs
297
+ mock_get .return_value = backup_data
298
+
299
+ result = crud .override_question ("qid123" , "New" , "New desc" , "Hard" , "Science" )
300
+
301
+ assert result == "qid123"
302
+ mock_create .assert_called_once_with ("New" , "New desc" , "Hard" , "Science" , None , "qid123" )
303
+
304
+
305
+ def test_question_not_found (mock_question_funcs ):
306
+ """Test QuestionNotFoundException is raised when question doesn't exist"""
307
+ mock_get , mock_delete , mock_create = mock_question_funcs
308
+ mock_get .side_effect = QuestionNotFoundException ("Not found" )
309
+
310
+ with pytest .raises (QuestionNotFoundException ):
311
+ crud .override_question ("nonexistent" , "New" , "New desc" , "Hard" , "Science" )
312
+
313
+
314
+ def test_create_failure_with_rollback (mock_question_funcs , backup_data ):
315
+ """Test rollback when create fails"""
316
+ mock_get , mock_delete , mock_create = mock_question_funcs
317
+ mock_get .return_value = backup_data
318
+ mock_create .side_effect = [Exception ("Create failed" ), None ] # Fail then succeed
319
+
320
+ with pytest .raises (Exception , match = "Create failed" ):
321
+ crud .override_question ("qid123" , "New" , "New desc" , "Hard" , "Science" )
322
+
323
+ assert mock_create .call_count == 2 # Original attempt + rollback
324
+
325
+
326
+ def test_create_failure_rollback_also_fails (mock_question_funcs , backup_data ):
327
+ """Test when both create and rollback fail"""
328
+ mock_get , mock_delete , mock_create = mock_question_funcs
329
+ mock_get .return_value = backup_data
330
+ mock_create .side_effect = Exception ("Always fails" )
331
+
332
+ with pytest .raises (Exception , match = "Always fails" ):
333
+ crud .override_question ("qid123" , "New" , "New desc" , "Hard" , "Science" )
334
+
335
+ assert mock_create .call_count == 2 # Original attempt + failed rollback
336
+
337
+
338
+ def test_delete_failure (mock_question_funcs , backup_data ):
339
+ """Test when delete_question fails"""
340
+ mock_get , mock_delete , mock_create = mock_question_funcs
341
+ mock_get .return_value = backup_data
342
+ mock_delete .side_effect = Exception ("Delete failed" )
343
+
344
+ with pytest .raises (Exception , match = "Delete failed" ):
345
+ crud .override_question ("qid123" , "New" , "New desc" , "Hard" , "Science" )
346
+
347
+ mock_create .assert_not_called () # Should not reach create step
0 commit comments