Skip to content

Commit e408edb

Browse files
committed
Add tests for crud
1 parent 5643fc4 commit e408edb

File tree

4 files changed

+353
-6
lines changed

4 files changed

+353
-6
lines changed

services/question-service/app/__init__.py

Whitespace-only changes.

services/question-service/app/crud.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import uuid
22
from typing import List, Dict, Optional
3-
from utils import get_conn, upload_to_s3, get_from_s3, delete_from_s3
4-
from models.exceptions import QuestionNotFoundException
3+
from app.utils import get_conn, upload_to_s3, get_from_s3, delete_from_s3
4+
from app.models.exceptions import QuestionNotFoundException
55

66
def list_difficulties_and_topics() -> Dict[str, List[str]]:
77
"""

services/question-service/app/main.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from fastapi import FastAPI, HTTPException, Query
22
from dotenv import load_dotenv
3-
from models.endpoint_models import QuestionBase64Images
4-
from models.exceptions import QuestionNotFoundException
5-
from utils import batch_convert_base64_to_bytes, batch_convert_bytes_to_base64
6-
from crud import (
3+
from app.models.endpoint_models import QuestionBase64Images
4+
from app.models.exceptions import QuestionNotFoundException
5+
from app.utils import batch_convert_base64_to_bytes, batch_convert_bytes_to_base64
6+
from app.crud import (
77
create_question,
88
get_question,
99
get_random_question_by_difficulty_and_topic,
Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
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

Comments
 (0)