Skip to content

Commit 4b98e55

Browse files
Complete mongo db integration for delete /books/{id} (#9)
-Refactored the DELETE endpoint to use a MongoDB helper for atomic deletes via `find_one_and_update`. Improved error handling to return 400 on invalid ObjectId and 404 for missing resources. -Added comprehensive tests for happy path, idempotency, non-existent records, and invalid ID formats. -Updated OpenAPI spec and ensured 100% Pylint coverage. --------- Co-authored-by: Copilot <[email protected]>
1 parent 56dbf0d commit 4b98e55

File tree

7 files changed

+190
-33
lines changed

7 files changed

+190
-33
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ htmlcov/
1414

1515

1616
# VSextension
17-
HTTP_scripts/
17+
HTTP_scripts/
18+
HTTP_request_examples

app/datastore/mongo_helper.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Module containing pymongo helper functions."""
22

3+
from bson.objectid import ObjectId
34
from pymongo.cursor import Cursor
45

56

@@ -62,3 +63,21 @@ def find_books(collection, query_filter=None, projection=None, limit=None) -> Cu
6263
if limit is not None and limit > 0:
6364
cursor = cursor.limit(limit)
6465
return cursor
66+
67+
68+
def delete_book_by_id(book_collection, book_id):
69+
"""
70+
Soft delete book with given id
71+
Returns: The original document if found and updated, otherwise None.
72+
"""
73+
# Convert string ID to ObjectId
74+
object_id_to_update = ObjectId(book_id)
75+
76+
# UPDATE operation
77+
update_operation = {"$set": {"state": "deleted"}}
78+
79+
filter_query = {"_id": object_id_to_update, "state": {"$ne": "deleted"}}
80+
81+
result = book_collection.find_one_and_update(filter_query, update_operation)
82+
83+
return result

app/routes.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
import copy
44

5-
from bson.objectid import ObjectId
5+
from bson.objectid import InvalidId, ObjectId
66
from flask import jsonify, request
77
from pymongo.errors import ConnectionFailure
88
from werkzeug.exceptions import HTTPException, NotFound
99

1010
from app.datastore.mongo_db import get_book_collection
11-
from app.datastore.mongo_helper import insert_book_to_mongo
11+
from app.datastore.mongo_helper import delete_book_by_id, insert_book_to_mongo
1212
from app.services.book_service import fetch_active_books, format_books_for_api
1313
from app.utils.api_security import require_api_key
1414
from app.utils.helper import append_hostname
@@ -179,14 +179,19 @@ def delete_book(book_id):
179179
"""
180180
Soft delete a book by setting its state to 'deleted' or return error if not found.
181181
"""
182-
if not books:
183-
return jsonify({"error": "Book collection not initialized"}), 500
182+
try:
183+
book_collection = get_book_collection()
184+
if book_collection is None:
185+
return jsonify({"error": "Book collection not initialized"}), 500
184186

185-
for book in books:
186-
if book.get("id") == book_id:
187-
book["state"] = "deleted"
188-
return "", 204
189-
return jsonify({"error": "Book not found"}), 404
187+
delete_result = delete_book_by_id(book_collection, book_id)
188+
189+
if delete_result is None:
190+
return jsonify({"error": "Book not found"}), 404
191+
192+
return "", 204
193+
except InvalidId:
194+
return jsonify({"error": "Invalid Book ID format"}), 400
190195

191196
# ----------- PUT section ------------------
192197

openapi.yml

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ paths:
350350
summary: Delete a book by ID
351351
security:
352352
- ApiKeyAuth: []
353-
description: Soft deletes a book by setting its state to 'deleted'.
353+
description: Soft deletes a book by setting its state to 'deleted'. This operation is idempotent.
354354
operationId: deleteBookById
355355
parameters:
356356
- name: book_id
@@ -365,9 +365,24 @@ paths:
365365
'204':
366366
description: Book deleted successfully.
367367
content: {}
368+
'400':
369+
description: Bad Request. The provided book_id is not in a valid 24-character hexadecimal format.
370+
content:
371+
application/json:
372+
schema:
373+
# You have a great SimpleError component we can reuse!
374+
$ref: '#/components/schemas/SimpleError'
375+
example:
376+
error: "Invalid Book ID format"
368377
'401':
369378
$ref: '#/components/responses/Unauthorized'
370379
'404':
371-
$ref: '#/components/responses/NotFound'
380+
description: A book with the specified ID was not found or was already deleted.
381+
content:
382+
application/json:
383+
schema:
384+
$ref: '#/components/schemas/SimpleError'
385+
example:
386+
error: "Book not found"
372387
'500':
373388
$ref: '#/components/responses/InternalServerError'

tests/test_api_security.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,12 +196,23 @@ def test_delete_book_fails_with_invalid_api_key(client):
196196

197197
def test_delete_book_succeeds_with_valid_api_key(client, monkeypatch):
198198
"""
199-
WHEN a DELETE request is made with a valid API key
200-
THEN it should return 200 OK (or appropriate response)
199+
GIVEN a valid API key
200+
WHEN a DELETE request is made to a valid book ID
201+
THEN the response should be 204 No Content
201202
"""
202-
monkeypatch.setattr("app.routes.books", [{"id": "some-id"}])
203+
# Define what a successful result from the DB helper is
204+
successful_db_result = {"_id": "some-id", "state": "active"}
205+
206+
monkeypatch.setattr(
207+
"app.routes.delete_book_by_id", lambda collection, book_id: successful_db_result
208+
)
209+
210+
monkeypatch.setattr("app.routes.get_book_collection", lambda: "a fake collection")
211+
212+
# Act
213+
valid_oid_string = "635c02a7a5f6e1e2b3f4d5e6"
214+
response = client.delete(f"/books/{valid_oid_string}", headers=HEADERS["VALID"])
203215

204-
response = client.delete("/books/some-id", headers=HEADERS["VALID"])
205216
assert response.status_code == 204
206217

207218

tests/test_app.py

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,9 @@ def test_get_book_returns_specified_book(
428428
assert response_data["author"] == "Kent Beck"
429429

430430

431-
def test_get_book_with_invalid_id_format_returns_400(client, db_setup): # pylint: disable=unused-argument
431+
def test_get_book_with_invalid_id_format_returns_400(
432+
client, db_setup
433+
): # pylint: disable=unused-argument
432434
# Arrange
433435
# an ID that is clearly not a valid MongoDB ObjectId
434436
invalid_book_id = "this-is-not-a-valid-id"
@@ -444,6 +446,7 @@ def test_get_book_with_invalid_id_format_returns_400(client, db_setup): # pylin
444446
expected_error = {"error": "Invalid book ID format"}
445447
assert response.get_json() == expected_error
446448

449+
447450
def test_get_book_not_found_returns_404(client, monkeypatch):
448451
"""
449452
WHEN given a well-formed but non-existent ObjectId,
@@ -508,18 +511,35 @@ def test_invalid_urls_return_404(client):
508511

509512
# ------------------------ Tests for DELETE --------------------------------------------
510513

514+
VALID_OID_STRING = "635c02a7a5f6e1e2b3f4d5e6"
515+
511516

512517
def test_book_is_soft_deleted_on_delete_request(client):
513-
with patch("app.routes.books", books_database):
514-
# Send DELETE request with valid API header
515-
book_id = "1"
516-
headers = {"X-API-KEY": "test-key-123"}
517-
response = client.delete(f"/books/{book_id}", headers=headers)
518+
"""
519+
GIVEN a valid book ID and API key
520+
WHEN a DELETE request is made
521+
THEN the view function should call the database helper correctly and return 204.
522+
523+
This test verifies the integration between the Flask route and the data layer.
524+
"""
525+
with patch("app.routes.delete_book_by_id") as mock_delete_helper:
526+
# Arrange
527+
# Configure the mock to simulate a successful deletion
528+
mock_delete_helper.return_value = {"_id": VALID_OID_STRING}
529+
530+
# Mock get_book_collection to avoid a real DB connection
531+
with patch("app.routes.get_book_collection", return_value="fake_collection"):
532+
# --- Act ---
533+
# Send the DELETE request using a valid API header.
534+
headers = {"X-API-KEY": "test-key-123"}
535+
response = client.delete(f"/books/{VALID_OID_STRING}", headers=headers)
518536

519537
assert response.status_code == 204
520-
assert response.data == b""
521-
# check that the book's state has changed to deleted
522-
assert books_database[0]["state"] == "deleted"
538+
mock_delete_helper.assert_called_once()
539+
mock_delete_helper.assert_called_once_with(
540+
"fake_collection", # The (mocked) collection object
541+
VALID_OID_STRING, # The ID passed from the URL
542+
)
523543

524544

525545
def test_delete_empty_book_id(client):
@@ -531,19 +551,51 @@ def test_delete_empty_book_id(client):
531551

532552

533553
def test_delete_invalid_book_id(client):
534-
headers = {"X-API-KEY": "test-key-123"}
535-
response = client.delete("/books/12341234", headers=headers)
536-
assert response.status_code == 404
554+
"""
555+
GIVEN a malformed book ID (not a valid ObjectId format)
556+
WHEN a DELETE request is made
557+
THEN the response should be 400 InvalidId Error.
558+
"""
559+
invalid_id = "1234-this-is-not-a-valid-id"
560+
561+
# Mock get_book_collection to avoid a real DB connection
562+
with patch("app.routes.get_book_collection", return_value="fake_collection"):
563+
# --- Act ---
564+
# Send the DELETE request using a valid API header.
565+
headers = {"X-API-KEY": "test-key-123"}
566+
response = client.delete(f"/books/{invalid_id}", headers=headers)
567+
568+
assert response.status_code == 400
537569
assert response.content_type == "application/json"
538-
assert "Book not found" in response.get_json()["error"]
570+
response_data = response.get_json()
571+
assert "error" in response_data
572+
assert "Invalid Book ID format" in response_data["error"]
539573

540574

541575
def test_book_database_is_initialized_for_delete_book_route(client):
542-
with patch("app.routes.books", None):
576+
with patch("app.routes.get_book_collection") as mock_get_collection:
577+
mock_get_collection.return_value = None
578+
543579
headers = {"X-API-KEY": "test-key-123"}
544-
response = client.delete("/books/1", headers=headers)
580+
response = client.delete(f"/books/{VALID_OID_STRING}", headers=headers)
581+
545582
assert response.status_code == 500
546-
assert "Book collection not initialized" in response.get_json()["error"]
583+
response_data = response.get_json()
584+
assert "error" in response_data
585+
assert "Book collection not initialized" in response_data["error"]
586+
587+
588+
def test_returns_404_if_helper_function_result_is_none(client):
589+
with patch("app.routes.delete_book_by_id") as mock_delete_book:
590+
mock_delete_book.return_value = None
591+
592+
headers = {"X-API-KEY": "test-key-123"}
593+
response = client.delete(f"/books/{VALID_OID_STRING}", headers=headers)
594+
595+
assert response.status_code == 404
596+
response_data = response.get_json()
597+
assert "error" in response_data
598+
assert "Book not found" in response_data["error"]
547599

548600

549601
# ------------------------ Tests for PUT --------------------------------------------

tests/test_mongo_helper.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
from unittest.mock import MagicMock
44

5-
from app.datastore.mongo_helper import find_books, insert_book_to_mongo
5+
from bson import ObjectId
6+
7+
from app.datastore.mongo_helper import (delete_book_by_id, find_books,
8+
insert_book_to_mongo)
69

710

811
def test_insert_book_to_mongo_calls_insert_one():
@@ -77,3 +80,54 @@ def test_find_books_applies_limit_when_provided():
7780

7881
# 3. Check that the function returned the FINAL cursor from the chain.
7982
assert result_cursor == mock_limited_cursor
83+
84+
85+
def test_delete_book_by_id_happy_path():
86+
"""Given a valid book_id, soft deletes the book"""
87+
# Arrange
88+
valid_id = ObjectId()
89+
fake_book_id_str = str(valid_id)
90+
fake_book_from_db = {
91+
"_id": valid_id,
92+
"title": "A Mocked Book",
93+
"author": "The Mockist",
94+
"synopsis": "A tale of fakes and stubs.",
95+
"state": "active",
96+
}
97+
98+
mock_collection = MagicMock()
99+
mock_collection.find_one_and_update.return_value = fake_book_from_db
100+
101+
# Act
102+
result = delete_book_by_id(mock_collection, fake_book_id_str)
103+
104+
# Assert
105+
mock_collection.find_one_and_update.assert_called_once()
106+
assert result == fake_book_from_db
107+
# Was the method called with the EXACT right arguments?
108+
expected_filter = {"_id": valid_id, "state": {"$ne": "deleted"}}
109+
expected_update = {"$set": {"state": "deleted"}}
110+
mock_collection.find_one_and_update.assert_called_once_with(
111+
expected_filter, expected_update
112+
)
113+
114+
115+
def test_soft_delete_already_deleted_book_returns_none():
116+
# Arrange
117+
valid_id = ObjectId()
118+
fake_book_id_str = str(valid_id)
119+
120+
mock_collection = MagicMock()
121+
mock_collection.find_one_and_update.return_value = None
122+
123+
# Act
124+
result = delete_book_by_id(mock_collection, fake_book_id_str)
125+
126+
# Assert
127+
assert result is None
128+
mock_collection.find_one_and_update.assert_called_once()
129+
expected_filter = {"_id": valid_id, "state": {"$ne": "deleted"}}
130+
expected_update = {"$set": {"state": "deleted"}}
131+
mock_collection.find_one_and_update.assert_called_with(
132+
expected_filter, expected_update
133+
)

0 commit comments

Comments
 (0)