Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
9aaa7d0
Add fixture setup for testing POST /reservation endpoint
codesungrape Aug 20, 2025
75a9f76
Add test for successful reservation creation (201)
codesungrape Aug 20, 2025
68f5d05
Add tests for book_id_str input validation/missing
codesungrape Aug 20, 2025
a94fcc9
Remove parametrize which triggers Flask's Wrezkrug 405 error
codesungrape Aug 21, 2025
ac24eac
Add tests for /reservations and non-existant book
codesungrape Aug 21, 2025
cf238b0
Add basic for happy path /reservations create_reservation
codesungrape Aug 21, 2025
f81e1b0
Disable pylint; import and register reservations_bp
codesungrape Aug 21, 2025
9c2f51c
Implement create_reservation in /routes/reservations.py
codesungrape Aug 21, 2025
47a6672
Update create_reservation() to use new datetime)
codesungrape Aug 21, 2025
450e676
Remove 'if not book_str' check as redundant
codesungrape Aug 21, 2025
f8a62f9
Refactor to make more robust ensuring payload is JSON
codesungrape Aug 21, 2025
320de32
Add parametrized tests for bad payloads
codesungrape Aug 21, 2025
f22ef13
Add parametrized test for invalid data fields
codesungrape Aug 21, 2025
3b59343
Fix spelling typo
codesungrape Aug 22, 2025
bc6afe6
Add require_jwt decorator & update create_reservation()
codesungrape Aug 23, 2025
655cb63
Add auth_token fixture to generate valid JWT
codesungrape Aug 23, 2025
9dd6caa
WIP- update to refect POST /reservations route
codesungrape Aug 23, 2025
0d5830e
Update invalid book reservation test with fixture
codesungrape Aug 23, 2025
28286ea
WIP- Fix fixtures scope mismatch; now default setting
codesungrape Aug 23, 2025
95a5582
Fix KeyError in decorator unit tests
codesungrape Aug 25, 2025
fe71005
Remove obsolete payload test after JWT adoption
codesungrape Aug 26, 2025
be9eaae
Remove obsolete test; update happy path to use JWT fixture
codesungrape Aug 26, 2025
4c54e95
config: use JWT_SECRET_KEY instead of SECRET_KEY
codesungrape Aug 26, 2025
25e6e3a
use JWT_SECRET_KEY in config and fix ObjectId mismatch
codesungrape Aug 26, 2025
970c7fb
Fix typo to enable disable pylint
codesungrape Aug 26, 2025
cedb48e
Run formatting
codesungrape Aug 26, 2025
8250db0
Fix key mismatch between login token encode and reservation decode
codesungrape Aug 26, 2025
819fa3c
Fix failing test to also use JWT_SECRET_KEY
codesungrape Aug 26, 2025
7d391ff
Add test preventing duplicate reservations by same user
codesungrape Aug 26, 2025
03146f6
Add logic to prevent duplicate reservations by same user
codesungrape Aug 26, 2025
1e08673
Update openapi.yml to reflect JWT-protected /reservations endpoint
codesungrape Aug 26, 2025
6426099
Fix typo
codesungrape Aug 26, 2025
1b83dc2
Run formatting
codesungrape Aug 26, 2025
5649693
Apply Co-pilot's suggestions: spelling/doc-strings
codesungrape Aug 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# pylint: disable=import-outside-toplevel
"""Initialize the Flask app and register all routes."""

import os
Expand All @@ -24,13 +25,13 @@ def create_app(test_config=None):
bcrypt.init_app(app)

# Import blueprints inside the factory
from app.routes.auth_routes import \
auth_bp # pylint: disable=import-outside-toplevel
from app.routes.legacy_routes import \
register_legacy_routes # pylint: disable=import-outside-toplevel
from app.routes.auth_routes import auth_bp
from app.routes.legacy_routes import register_legacy_routes
from app.routes.reservation_routes import reservations_bp

# Register routes with app instance
register_legacy_routes(app)
app.register_blueprint(auth_bp)
app.register_blueprint(reservations_bp)

return app
1 change: 1 addition & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class Config:

# General config
SECRET_KEY = os.environ.get("SECRET_KEY")
JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY")
FLASK_APP = os.environ.get("FLASK_APP")
FLASK_ENV = os.environ.get("FLASK") # values will be 'development' or 'production'
API_KEY = os.environ.get("API_KEY")
Expand Down
4 changes: 2 additions & 2 deletions app/routes/auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,11 @@ def login_user():
+ datetime.timedelta(hours=24), # expiration
}

# 5. Encode the token with our app's SECRET_KEY
# 5. Encode the token with our app's JWT_SECRET_KEY
try:
token = jwt.encode(
payload,
current_app.config["SECRET_KEY"],
current_app.config["JWT_SECRET_KEY"],
algorithm="HS256", # the standard signing algorithm
)
return jsonify({"token": token}), 200
Expand Down
78 changes: 78 additions & 0 deletions app/routes/reservation_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Routes for /books/<id>/reservations endpoint"""

import datetime

from bson import ObjectId
from bson.errors import InvalidId
from flask import Blueprint, g, jsonify, url_for

from app.extensions import mongo
from app.utils.decorators import require_jwt

reservations_bp = Blueprint(
"reservations_bp", __name__, url_prefix="/books/<book_id_str>"
)


@reservations_bp.route("/reservations", methods=["POST"])
@require_jwt
def create_reservation(book_id_str):
"""
This POST endpoint lets an authenticated user reserve a book by its ID.
It validates the ID, checks book availability,
prevents duplicate reservations,
creates the reservation, and returns its details.
"""

# ---------- VALIDATION 1 - check payload has valid id - mongoDB id shape

try:
# convert string to an ObjectId
book_id = ObjectId(book_id_str)
except (InvalidId, TypeError):
return jsonify({"error": "Invalid Book ID"}), 400

# Check if book exists or throw 404
book = mongo.db.books.find_one({"_id": book_id})
if not book:
return jsonify({"error": "Book not found"}), 404

# Get the current user directly from Flask's 'g'
current_user_id = g.current_user["_id"]

# ---------- VALIDATION 2 - Check for existing reservation
# A user should not be able to reserve the same book more than once.
existing_reservation = mongo.db.reservations.find_one(
{"book_id": book_id, "user_id": current_user_id}
)
if existing_reservation:
return jsonify({"error": "You have already reserved this book"}), 409

# 2. DOCUMENT CREATION
reservation_doc = {
"book_id": book_id,
"state": "reserved",
"user_id": current_user_id,
"reservation_date": datetime.datetime.now(datetime.UTC),
}

# Insert new reservation to MongoDb 'reservation' collection
result = mongo.db.reservations.insert_one(reservation_doc)
new_reservation_id = result.inserted_id

# 3. RESPONSE PREP
response_data = {
"id": str(new_reservation_id),
"state": reservation_doc["state"],
"user_id": reservation_doc["user_id"],
"book_id": str(reservation_doc["book_id"]),
"links": {
"self": url_for(
".create_reservation", book_id_str=str(book_id), _external=True
),
"book": url_for("get_book", book_id=str(book_id), _external=True),
},
"reservation_date": reservation_doc["reservation_date"].isoformat(),
}

return jsonify(response_data), 201
8 changes: 5 additions & 3 deletions app/utils/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
This module provides decorators for Flask routes, including JWT authentication.
"""
import functools

import jwt
from flask import current_app, g, jsonify, request
from bson.objectid import ObjectId
from bson.errors import InvalidId
from bson.objectid import ObjectId
from flask import current_app, g, jsonify, request

from app.extensions import mongo


Expand Down Expand Up @@ -34,7 +36,7 @@ def decorated_function(*args, **kwargs):
try:
payload = jwt.decode(
token,
current_app.config["SECRET_KEY"],
current_app.config["JWT_SECRET_KEY"],
algorithms=["HS256"],
# options={"require": ["exp", "sub"]} # optional: force required claims
)
Expand Down
108 changes: 69 additions & 39 deletions openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -180,41 +180,34 @@ components:

# ------- Reservation schemas ----------

# Schema for what a User looks like
User:
# Schema for the PUT /reservations/{res_id} request body
ReservationUserOutput:
type: object
properties:
userId:
forenames:
type: string
example: "user-12345"
name:
example: "John"
middlenames:
type: string
example: "Jane Doe"

# Schema for the POST /reservations request body
ReservationInput:
type: object
required:
- user
properties:
user:
$ref: '#/components/schemas/User'

# Schema for the PUT /reservations/{res_id} request body
ReservationUpdateInput:
example: "Fitzgerald"
surname:
type: string
example: "Doe"

# A schema for the 'links' object in the response.
ReservationLinks:
type: object
description: The fields required to fully update a reservation.
required:
- user
- reservationDate
properties:
user:
$ref: '#/components/schemas/User'
reservationDate:
self:
type: string
format: date-time
description: The new timestamp for the reservation.
example: "2023-10-28T12:00:00Z"
format: uri
description: A link to the reservation resource itself.
book:
type: string
format: uri
description: A link to the parent book resource.
readOnly: true


# Schema for the full Reservation object returned by the API
ReservationOutput:
Expand All @@ -225,18 +218,29 @@ components:
description: The unique identifier for the reservation.
pattern: '^[a-f\d]{24}$'
example: "635f3a7f3a8e3bcfc8e6a1f1"
bookId:
state:
type: string
description: The current state of the reservation.
example: "reserved"
readOnly: true
user_id:
type: string
description: The ID of the user who made the reservation.
pattern: '^[a-f\d]{24}$'
example: "635f3a7e3a8e3bcfc8e6a1e0"
book_id:
type: string
description: The ID of the book being reserved.
pattern: '^[a-f\d]{24}$'
example: "635f3a7e3a8e3bcfc8e6a1e0"
links:
$ref: '#/components/schemas/ReservationLinks'
reservationDate:
type: string
format: date-time
description: The timestamp when the reservation was made.
example: "2023-10-27T10:00:00Z"
user:
$ref: "#/components/schemas/User"


# Schema for the GET /reservations response body (a list)
ReservationsOutput:
Expand Down Expand Up @@ -535,15 +539,15 @@ paths:
tags:
- Reservations
summary: Create a reservation for a book.
description: Creates a reservation for the book identified by {book_id}.
operationId: create_book_reservation
description: Creates a reservation for the book identified by {book_id} on behalf of the authenticated user. A valid JWT Bearer token is required.
operationId: createBookReservation
security:
- bearerAuth: []
requestBody:
description: User information for the reservation.
required: true
description: The request body for this endpoint is ignored. An empty JSON object `{}` is recommended.
content:
application/json:
schema:
$ref: '#/components/schemas/ReservationInput'
schema: {}
responses:
'201':
description: Reservation created successfully.
Expand All @@ -552,11 +556,37 @@ paths:
schema:
$ref: '#/components/schemas/ReservationOutput'
'400':
$ref: '#/components/responses/BadRequest'
description: |-
Bad Request. Possible reasons include:
- The book ID in the path is not a valid MongoDB ObjectId.
- The request body is malformed (e.g., not valid JSON).
content:
application/json:
schema:
$ref: '#/components/schemas/SimpleError'
examples:
invalidId:
summary: Invalid Book ID
value:
error: "Invalid Book ID"
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
description: The book with the specified ID was not found.
content:
application/json:
schema:
$ref: '#/components/schemas/SimpleError'
example:
error: "Book not found"
'409':
description: A reservation for this book by the authenticated user already exists.
content:
application/json:
schema:
$ref: '#/components/schemas/SimpleError'
example:
error: "You have already reserved this book"
'415':
$ref: '#/components/responses/UnsupportedMediaType'
'500':
Expand Down
14 changes: 10 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import bcrypt
import mongomock
import pytest
from bson.objectid import ObjectId

from app import create_app
from app.datastore.mongo_db import get_book_collection
Expand Down Expand Up @@ -76,6 +77,7 @@ def test_app():
"TRAP_HTTP_EXCEPTIONS": True,
"API_KEY": "test-key-123",
"SECRET_KEY": "a-secure-key-for-testing-only",
"JWT_SECRET_KEY": "a-secure-jwt-key-for-testing-only",
"MONGO_URI": "mongodb://localhost:27017/",
"DB_NAME": "test_database",
"COLLECTION_NAME": "test_books",
Expand Down Expand Up @@ -150,7 +152,7 @@ def mock_user_data():
hashed_password = bcrypt.generate_password_hash(PLAIN_PASSWORD).decode("utf-8")

return {
"_id": TEST_USER_ID,
"_id": ObjectId(TEST_USER_ID),
"email": "testuser@example.com",
"password": hashed_password,
}
Expand All @@ -172,6 +174,10 @@ def seeded_user_in_db(
with test_app.app_context():
mongo.db.users.insert_one(mock_user_data)

# yield the user data in case a test needs it
# but often we just need the side-effect of the user being in the DB
yield mock_user_data
# When yielding the mock data back to the test,
# we must convert it back the ObjectId back to the string,
# because that's what 'auth_token' fixture expects to put into the JWT 'sub' claim
yield_data = mock_user_data.copy()
yield_data["_id"] = str(yield_data["_id"])

yield yield_data
5 changes: 3 additions & 2 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

import jwt
import pytest
from conftest import PLAIN_PASSWORD, TEST_USER_ID # pylint: disable=import-error
from conftest import (PLAIN_PASSWORD, # pylint: disable=import-error
TEST_USER_ID)

from app import bcrypt, mongo

Expand Down Expand Up @@ -184,7 +185,7 @@ def test_login_user_returns_jwt_for_valid_credentials(
with test_app.app_context():
# check token: we need the SECRET_KEY from the app config to decode it.
payload = jwt.decode(
data["token"], test_app.config["SECRET_KEY"], algorithms=["HS256"]
data["token"], test_app.config["JWT_SECRET_KEY"], algorithms=["HS256"]
)
assert payload["sub"] == TEST_USER_ID

Expand Down
Loading