Skip to content

Commit b97edbb

Browse files
committed
feat: Blog Update API Endpoint [Issue #5]
1 parent 4939d87 commit b97edbb

File tree

7 files changed

+304
-6
lines changed

7 files changed

+304
-6
lines changed

.coveragerc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[run]
2+
branch = True
3+
omit = */__init__.py
4+
5+
[report]
6+
show_missing = True

Makefile

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,6 @@ TOX = $(VENV_DIR)/bin/tox
2020
# Define the pre-commit executable within the virtual environment
2121
PRE_COMMIT = $(VENV_DIR)/bin/pre-commit
2222

23-
# Define the uvicorn executable within the virtual environment
24-
UVICORN = $(VENV_DIR)/bin/uvicorn
25-
2623
# Define the alembic executable within the virtual environment
2724
ALEMBIC = $(VENV_DIR)/bin/alembic
2825

@@ -53,7 +50,7 @@ check: ## Run all checks using tox and pre-commit
5350
@echo "All checks passed"
5451

5552
run: ## Run the development server
56-
$(UVICORN) main:app --reload
53+
@fastapi dev
5754

5855
clean: ## Clean up the project
5956
@echo "Cleaning up the project of temporary files and directories..."

api/v1/routes/blog.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
BlogListItemResponseSchema,
1313
BlogListResponseSchema,
1414
BlogResponseSchema,
15+
BlogUpdateSchema,
1516
)
1617

1718
blog = APIRouter(prefix="/blogs", tags=["Blog"])
@@ -48,7 +49,15 @@ async def create_blog(
4849
db.refresh(new_blog)
4950
logger.info(f"Blog post '{new_blog.title}' created successfully.")
5051

51-
return BlogResponseSchema.model_validate(new_blog.__dict__)
52+
return BlogResponseSchema(
53+
id=new_blog.id,
54+
title=new_blog.title,
55+
excerpt=new_blog.excerpt,
56+
content=new_blog.content,
57+
image_url=new_blog.image_url,
58+
created_at=new_blog.created_at,
59+
updated_at=new_blog.updated_at,
60+
)
5261

5362
except HTTPException as http_err:
5463
logger.warning(f"HTTP error occurred: {http_err.detail}")
@@ -171,6 +180,96 @@ async def read_blog(
171180
)
172181

173182

183+
@blog.patch(
184+
"/{id}",
185+
response_model=BlogResponseSchema,
186+
status_code=status.HTTP_200_OK,
187+
)
188+
def update_blog(
189+
id: int,
190+
blog_update: BlogUpdateSchema,
191+
db: Session = Depends(get_db),
192+
) -> BlogResponseSchema:
193+
try:
194+
# Fetch the blog post to be updated
195+
blog = (
196+
db.query(Blog)
197+
.filter(
198+
Blog.id == id,
199+
not_(Blog.is_deleted),
200+
)
201+
.first()
202+
)
203+
if not blog:
204+
raise HTTPException(
205+
status_code=status.HTTP_404_NOT_FOUND,
206+
detail="Blog post not found.",
207+
)
208+
209+
# Get the updated data
210+
update_data = blog_update.model_dump(exclude_unset=True)
211+
212+
# Check for title uniqueness
213+
if "title" in update_data and update_data["title"] != blog.title:
214+
existing_blog = (
215+
db.query(Blog)
216+
.filter(
217+
Blog.title == update_data["title"],
218+
not_(Blog.is_deleted),
219+
)
220+
.first()
221+
)
222+
if existing_blog:
223+
logger.warning(
224+
f"Blog post with title '{update_data['title']}' already exists."
225+
)
226+
raise HTTPException(
227+
status_code=status.HTTP_409_CONFLICT,
228+
detail="A blog post with this title already exists.",
229+
)
230+
231+
# Update fields if they are provided
232+
for field, value in update_data.items():
233+
setattr(blog, field, value)
234+
235+
db.commit()
236+
db.refresh(blog)
237+
logger.info(f"Blog post '{blog.title}' updated successfully.")
238+
239+
return BlogResponseSchema(
240+
id=blog.id,
241+
title=blog.title,
242+
excerpt=blog.excerpt,
243+
content=blog.content,
244+
image_url=blog.image_url,
245+
created_at=blog.created_at,
246+
updated_at=blog.updated_at,
247+
)
248+
249+
except HTTPException as http_err:
250+
# Log the HTTP exception
251+
logger.warning(f"HTTP error occurred: {http_err.detail}")
252+
raise http_err
253+
254+
except SQLAlchemyError as sql_err:
255+
# Log SQLAlchemy errors
256+
logger.error(f"Database error occurred: {sql_err}")
257+
db.rollback()
258+
raise HTTPException(
259+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
260+
detail="Database error occurred.",
261+
)
262+
263+
except Exception as e:
264+
# Log other unexpected errors
265+
logger.error(f"Unexpected error occurred: {e}")
266+
db.rollback()
267+
raise HTTPException(
268+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
269+
detail="Internal server error.",
270+
)
271+
272+
174273
@blog.delete(
175274
"/{id}",
176275
status_code=status.HTTP_204_NO_CONTENT,

api/v1/schemas/blog.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,12 @@ class BlogListResponseSchema(BaseModel):
7070
results: List[BlogListItemResponseSchema]
7171

7272
model_config = {"from_attributes": True}
73+
74+
75+
class BlogUpdateSchema(BaseModel):
76+
title: str
77+
excerpt: str
78+
content: str
79+
image_url: str
80+
81+
model_config = {"from_attributes": True}

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ fastapi==0.111.1
44
psycopg2-binary==2.9.9
55
pydantic-settings==2.3.4
66
pytest==8.3.1
7+
pytest-cov==5.0.0
78
pytest-mock==3.14.0
89
python-decouple==3.8
910
SQLAlchemy==2.0.31

tests/v1/blog/test_update_blog.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
from datetime import datetime, timezone
2+
from unittest.mock import MagicMock
3+
4+
import pytest
5+
from fastapi.testclient import TestClient
6+
7+
from api.db.database import get_db
8+
from api.v1.models.blog import Blog
9+
from main import app
10+
11+
client = TestClient(app)
12+
13+
14+
@pytest.fixture
15+
def db_session_mock():
16+
return MagicMock()
17+
18+
19+
@pytest.fixture(autouse=True)
20+
def override_get_db(db_session_mock):
21+
app.dependency_overrides[get_db] = lambda: db_session_mock
22+
yield
23+
app.dependency_overrides[get_db] = None
24+
25+
26+
def test_update_blog_success(db_session_mock):
27+
"""Ensure that the endpoint successfully updates the data of an existing blog post."""
28+
existing_blog = Blog(
29+
id=1,
30+
title="Existing Blog Post",
31+
excerpt="A summary of the blog post...",
32+
content="The content of the blog post...",
33+
image_url="image-url-link",
34+
created_at=datetime.now(timezone.utc),
35+
updated_at=datetime.now(timezone.utc),
36+
)
37+
updated_data = {
38+
"title": "Updated Blog Post",
39+
"excerpt": "An updated summary...",
40+
"content": "Updated content...",
41+
"image_url": "updated-image-url-link",
42+
}
43+
44+
db_session_mock.query.return_value.filter.return_value.first.side_effect = [
45+
existing_blog, # Return the existing blog for the first query
46+
None, # Return None for the second query to simulate no conflict
47+
]
48+
db_session_mock.commit.side_effect = lambda: None
49+
db_session_mock.refresh.side_effect = lambda blog: setattr(
50+
blog, "updated_at", datetime.now(timezone.utc)
51+
)
52+
53+
response = client.patch(f"/api/v1/blogs/{existing_blog.id}", json=updated_data)
54+
55+
assert response.status_code == 200
56+
response_data = response.json()
57+
assert response_data["title"] == updated_data["title"]
58+
assert response_data["excerpt"] == updated_data["excerpt"]
59+
assert response_data["content"] == updated_data["content"]
60+
assert response_data["image_url"] == updated_data["image_url"]
61+
assert "updated_at" in response_data
62+
63+
64+
def test_update_blog_conflict(db_session_mock):
65+
"""Simulate a request to update a blog post with a title that already exists."""
66+
existing_blog = Blog(
67+
id=1,
68+
title="Existing Blog Post",
69+
excerpt="A summary of the blog post...",
70+
content="The content of the blog post...",
71+
image_url="image-url-link",
72+
created_at=datetime.now(timezone.utc),
73+
updated_at=datetime.now(timezone.utc),
74+
)
75+
conflicting_blog = Blog(
76+
id=2,
77+
title="Updated Blog Post",
78+
excerpt="Another summary...",
79+
content="Another content...",
80+
image_url="another-image-url-link",
81+
created_at=datetime.now(timezone.utc),
82+
updated_at=datetime.now(timezone.utc),
83+
)
84+
updated_data = {
85+
"title": "Updated Blog Post",
86+
"excerpt": "An updated summary...",
87+
"content": "Updated content...",
88+
"image_url": "updated-image-url-link",
89+
}
90+
91+
db_session_mock.query.return_value.filter.return_value.first.side_effect = [
92+
existing_blog, # Return the existing blog for the first query
93+
conflicting_blog, # Return the conflicting blog for the second query
94+
]
95+
96+
response = client.patch(f"/api/v1/blogs/{existing_blog.id}", json=updated_data)
97+
98+
assert response.status_code == 409
99+
assert response.json()["detail"] == "A blog post with this title already exists."
100+
101+
102+
def test_update_blog_not_found(db_session_mock):
103+
"""Simulate a request to update a blog post that does not exist."""
104+
updated_data = {
105+
"title": "Updated Blog Post",
106+
"excerpt": "An updated summary...",
107+
"content": "Updated content...",
108+
"image_url": "updated-image-url-link",
109+
}
110+
111+
db_session_mock.query.return_value.filter.return_value.first.return_value = None
112+
113+
response = client.patch("/api/v1/blogs/999", json=updated_data)
114+
115+
assert response.status_code == 404
116+
assert response.json()["detail"] == "Blog post not found."
117+
118+
119+
def test_update_blog_internal_server_error(db_session_mock):
120+
"""Simulate an internal server error to raise an exception."""
121+
updated_data = {
122+
"title": "Updated Blog Post",
123+
"excerpt": "An updated summary...",
124+
"content": "Updated content...",
125+
"image_url": "updated-image-url-link",
126+
}
127+
128+
db_session_mock.query.side_effect = Exception("Unexpected error")
129+
130+
response = client.patch("/api/v1/blogs/1", json=updated_data)
131+
132+
assert response.status_code == 500
133+
assert response.json()["detail"] == "Internal server error."
134+
135+
136+
def test_update_blog_invalid_data():
137+
"""Send requests with invalid data and check for validation errors."""
138+
invalid_data = {
139+
"excerpt": "An updated summary...",
140+
"content": "Updated content...",
141+
"image_url": "updated-image-url-link",
142+
}
143+
144+
response = client.patch("/api/v1/blogs/1", json=invalid_data)
145+
146+
assert response.status_code == 422
147+
148+
149+
def test_update_blog_boundary_testing(db_session_mock):
150+
"""Test the maximum length constraints for the title and excerpt fields."""
151+
existing_blog = Blog(
152+
id=1,
153+
title="Existing Blog Post",
154+
excerpt="A summary of the blog post...",
155+
content="The content of the blog post...",
156+
image_url="image-url-link",
157+
created_at=datetime.now(timezone.utc),
158+
updated_at=datetime.now(timezone.utc),
159+
)
160+
boundary_blog_data = {
161+
"title": "T" * 255, # Maximum allowed length for title
162+
"excerpt": "E" * 300, # Maximum allowed length for excerpt
163+
"content": "Content of the blog post...",
164+
"image_url": "image-url-link",
165+
}
166+
167+
db_session_mock.query.return_value.filter.return_value.first.side_effect = [
168+
existing_blog, # Return the existing blog for the first query
169+
None, # Return None for the second query to simulate no conflict
170+
]
171+
db_session_mock.commit.side_effect = lambda: None
172+
db_session_mock.refresh.side_effect = lambda blog: setattr(
173+
blog, "updated_at", datetime.now(timezone.utc)
174+
)
175+
176+
response = client.patch(
177+
f"/api/v1/blogs/{existing_blog.id}", json=boundary_blog_data
178+
)
179+
180+
assert response.status_code == 200
181+
response_data = response.json()
182+
assert response_data["title"] == boundary_blog_data["title"]
183+
assert response_data["excerpt"] == boundary_blog_data["excerpt"]
184+
assert response_data["content"] == boundary_blog_data["content"]
185+
assert response_data["image_url"] == boundary_blog_data["image_url"]
186+
assert "updated_at" in response_data

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ basepython = python3.10
1414
deps =
1515
-r requirements.txt
1616
commands =
17-
pytest
17+
pytest --cov-config=.coveragerc --cov=api --cov-report=html:htmlcov --cov-report=term-missing tests/
1818

1919
[testenv:type]
2020
description = run type checks

0 commit comments

Comments
 (0)