Skip to content

Commit 53cc810

Browse files
committed
refactor: unittest now only test the service layer
before they were wrongfully testing the whole application with routes and repostiory layer, and that is responsibility of integration tests
1 parent 4acbeac commit 53cc810

14 files changed

Lines changed: 375 additions & 282 deletions

File tree

Makefile

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,15 @@
22

33
help:
44
@echo "Available commands:"
5-
@echo " build - Build the docker container"
65
@echo " run - Run the application (development with auto-reload, debug mode)"
6+
@echo " stop - Stop the application"
77
@echo " test - Run all tests"
88
@echo " test-unit - Run unit tests"
99
@echo " test-integration - Run integration tests with fresh database"
10-
@echo " stop - Stop the application"
1110
@echo " clean - Clean up Docker containers and volumes"
1211

13-
build:
14-
docker build -t python-fastapi-example-oms:dev .
15-
1612
run:
17-
$(MAKE) build
18-
docker compose up -d
13+
docker compose up --build -d
1914
@echo "Application running at http://localhost:8000"
2015
@echo "Swagger UI is available at http://localhost:8000/docs"
2116

@@ -28,9 +23,8 @@ test:
2823
$(MAKE) test-integration
2924

3025
test-unit:
31-
$(MAKE) build
3226
@echo "Running unit tests..."
33-
docker compose run --rm app python -m pytest tests/unit/ -v
27+
docker run --rm --tty -v $(PWD):/app python-fastapi-example-oms:dev python -m pytest tests/unit/ -v
3428

3529
test-integration:
3630
$(MAKE) clean

app/modules/orders/repository.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,38 @@
22
from . import models
33

44

5-
def create_order(db: Session, item: str):
6-
order = models.Order(item=item)
7-
db.add(order)
8-
db.commit()
9-
db.refresh(order)
10-
return order
5+
class OrderRepository:
6+
def __init__(self, db: Session):
7+
self._db = db
118

9+
def create_order(self, item: str):
10+
order = models.Order(item=item)
11+
self._db.add(order)
12+
self._db.commit()
13+
self._db.refresh(order)
14+
return order
1215

13-
def get_order(db: Session, order_id: int):
14-
return db.query(models.Order).filter(models.Order.id == order_id).first()
16+
def get_order(self, order_id: int):
17+
return self._db.query(models.Order).filter(models.Order.id == order_id).first()
1518

19+
def list_orders(self):
20+
return self._db.query(models.Order).all()
1621

17-
def list_orders(db: Session):
18-
return db.query(models.Order).all()
22+
def update_order(self, order_id: int, **kwargs):
23+
order = self._db.query(models.Order).filter(models.Order.id == order_id).first()
24+
if not order:
25+
raise ValueError("Order not found")
26+
for field, value in kwargs.items():
27+
if value is not None:
28+
setattr(order, field, value)
29+
self._db.commit()
30+
self._db.refresh(order)
31+
return order
32+
33+
def delete_order(self, order_id: int):
34+
order = self._db.query(models.Order).filter(models.Order.id == order_id).first()
35+
if not order:
36+
raise ValueError("Order not found")
37+
self._db.delete(order)
38+
self._db.commit()
39+
return True

app/modules/orders/routes.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,45 @@
22
from sqlalchemy.orm import Session
33

44
from app.db.session import get_db
5-
from . import service, schemas
5+
from . import service, schemas, repository
66

77
router = APIRouter()
88

9-
# TODO: Require user authentication when calling these paths
9+
10+
def get_orders_repository(db: Session = Depends(get_db)):
11+
return repository.OrderRepository(db)
12+
1013

1114
@router.post("/", response_model=schemas.OrderRead)
12-
def create_order(payload: schemas.OrderCreate, db: Session = Depends(get_db)):
13-
return service.create_order(db, payload.item)
15+
def create_order(payload: schemas.OrderCreate, repo: repository.OrderRepository = Depends(get_orders_repository)):
16+
return service.create_order(repo, payload.item)
1417

1518

1619
@router.get("/{order_id}", response_model=schemas.OrderRead)
17-
def get_order(order_id: int, db: Session = Depends(get_db)):
20+
def get_order(order_id: int, repo: repository.OrderRepository = Depends(get_orders_repository)):
1821
try:
19-
return service.get_order(db, order_id)
22+
return service.get_order(repo, order_id)
2023
except ValueError:
2124
raise HTTPException(status_code=404, detail="Order not found")
2225

2326

2427
@router.get("/", response_model=list[schemas.OrderRead])
25-
def list_orders(db: Session = Depends(get_db)):
26-
return service.list_orders(db)
28+
def list_orders(repo: repository.OrderRepository = Depends(get_orders_repository)):
29+
return service.list_orders(repo)
30+
31+
32+
@router.put("/{order_id}", response_model=schemas.OrderRead)
33+
def update_order(order_id: int, payload: schemas.OrderUpdate, repo: repository.OrderRepository = Depends(get_orders_repository)):
34+
try:
35+
updates = {k: v for k, v in payload.model_dump(exclude_unset=True).items()}
36+
return service.update_order(repo, order_id, **updates)
37+
except ValueError:
38+
raise HTTPException(status_code=404, detail="Order not found")
39+
40+
41+
@router.delete("/{order_id}", status_code=204)
42+
def delete_order(order_id: int, repo: repository.OrderRepository = Depends(get_orders_repository)):
43+
try:
44+
service.delete_order(repo, order_id)
45+
except ValueError:
46+
raise HTTPException(status_code=404, detail="Order not found")

app/modules/orders/schemas.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ class OrderCreate(BaseModel):
66
item: str
77

88

9+
class OrderUpdate(BaseModel):
10+
"""Partial update schema for orders. Only provided fields are applied."""
11+
item: str | None = None
12+
status: OrderStatus | None = None
13+
14+
915
class OrderRead(BaseModel):
1016
id: int
1117
item: str

app/modules/orders/service.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,38 @@
1-
from sqlalchemy.orm import Session
2-
from . import repository, models
1+
from . import repository
32

43

5-
def create_order(db: Session, item: str):
6-
return repository.create_order(db, item)
4+
def create_order(repo: repository.OrderRepository, item: str):
5+
return repo.create_order(item)
76

87

9-
def get_order(db: Session, order_id: int):
10-
order = repository.get_order(db, order_id)
8+
def get_order(repo: repository.OrderRepository, order_id: int):
9+
order = repo.get_order(order_id)
1110
if not order:
1211
raise ValueError("Order not found")
1312
return order
1413

1514

16-
def list_orders(db: Session):
17-
return repository.list_orders(db)
15+
def list_orders(repo: repository.OrderRepository):
16+
return repo.list_orders()
17+
18+
19+
def update_order(repo: repository.OrderRepository, order_id: int, **kwargs):
20+
"""Update an order with the provided fields. Raises ValueError if not found."""
21+
order = repo.get_order(order_id)
22+
if not order:
23+
raise ValueError("Order not found")
24+
for key, value in kwargs.items():
25+
if value is not None:
26+
setattr(order, key, value)
27+
repo._db.commit()
28+
repo._db.refresh(order)
29+
return order
30+
31+
32+
def delete_order(repo: repository.OrderRepository, order_id: int):
33+
"""Delete an order. Raises ValueError if not found."""
34+
order = repo.get_order(order_id)
35+
if not order:
36+
raise ValueError("Order not found")
37+
repo._db.delete(order)
38+
repo._db.commit()

app/modules/users/repository.py

Lines changed: 26 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,38 @@
1-
from sqlalchemy.orm import Session
2-
from . import models
31
import bcrypt
42
import hashlib
3+
from sqlalchemy.orm import Session
4+
from . import models
55

66

7-
def create_user(db: Session, username: str, password: str):
8-
# TODO: Add password strength check, should be long (20+ chars) OR complex (with uppercase, lowercase, numbers and symbols)
9-
10-
# Hash the password with bcrypt
11-
# bcrypt has a 72-byte limit, so we'll hash long passwords with SHA-256 first
12-
if len(password) > 72:
13-
password = hashlib.sha256(password.encode()).hexdigest()
14-
15-
# Hash with bcrypt
16-
hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
17-
18-
# Create user object
19-
user = models.User(username=username, hashed_password=hashed_password.decode('utf-8'))
7+
class UserRepository:
8+
def __init__(self, db: Session):
9+
self._db = db
2010

21-
# Add to database
22-
db.add(user)
23-
db.commit()
24-
db.refresh(user)
25-
return user
11+
def create_user(self, username: str, password: str):
12+
hashed_password = self._hash_password(password)
13+
user = models.User(username=username, hashed_password=hashed_password)
14+
self._db.add(user)
15+
self._db.commit()
16+
self._db.refresh(user)
17+
return user
2618

19+
def get_user_by_username(self, username: str):
20+
return self._db.query(models.User).filter(models.User.username == username).first()
2721

28-
def get_user_by_username(db: Session, username: str):
29-
return db.query(models.User).filter(models.User.username == username).first()
22+
def get_user_by_id(self, user_id: int):
23+
return self._db.query(models.User).filter(models.User.id == user_id).first()
3024

25+
def _hash_password(self, password: str) -> str:
26+
if len(password) > 72:
27+
password = hashlib.sha256(password.encode()).hexdigest()
28+
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
3129

32-
def get_user_by_id(db: Session, user_id: int):
33-
return db.query(models.User).filter(models.User.id == user_id).first()
30+
@staticmethod
31+
def verify_password(plain_password: str, hashed_password: str) -> bool:
32+
if len(plain_password) > 72:
33+
plain_password = hashlib.sha256(plain_password.encode()).hexdigest()
34+
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
3435

3536

3637
def verify_password(plain_password: str, hashed_password: str) -> bool:
37-
# Handle long passwords by hashing them first
38-
if len(plain_password) > 72:
39-
plain_password = hashlib.sha256(plain_password.encode()).hexdigest()
40-
41-
# Verify password
42-
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
38+
return UserRepository.verify_password(plain_password, hashed_password)

app/modules/users/routes.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,32 @@
22
from sqlalchemy.orm import Session
33

44
from app.db.session import get_db
5-
from . import service, schemas
5+
from . import service, schemas, repository
66

77
router = APIRouter()
88

99

10+
def get_users_repository(db: Session = Depends(get_db)):
11+
return repository.UserRepository(db)
12+
13+
1014
@router.post("/register", response_model=schemas.UserRead)
11-
def register_user(payload: schemas.UserCreate, db: Session = Depends(get_db)):
15+
def register_user(payload: schemas.UserCreate, repo: repository.UserRepository = Depends(get_users_repository)):
1216
try:
13-
return service.create_user(db, payload.username, payload.password)
17+
return service.create_user(repo, payload.username, payload.password)
1418
except HTTPException:
15-
raise # Re-raise HTTP exceptions
16-
except Exception as e:
19+
raise
20+
except Exception:
1721
raise HTTPException(
1822
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1923
detail="Error creating user"
2024
)
2125

2226

2327
@router.post("/login", response_model=schemas.UserToken)
24-
def login_user(payload: schemas.UserAuthenticate, db: Session = Depends(get_db)):
25-
user = service.authenticate_user(db, payload.username, payload.password)
26-
27-
# In a real application, you would generate a JWT token here
28-
# For now, we'll return a simple token structure
28+
def login_user(payload: schemas.UserAuthenticate, repo: repository.UserRepository = Depends(get_users_repository)):
29+
user = service.authenticate_user(repo, payload.username, payload.password)
2930
return schemas.UserToken(
3031
access_token=f"fake-jwt-token-for-{user.username}",
3132
token_type="bearer"
32-
)
33+
)

app/modules/users/service.py

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,23 @@
1-
from sqlalchemy.orm import Session
21
from . import repository, schemas
32
from fastapi import HTTPException, status
43

5-
# TODO: Evaluate if the logic to check for user existance should actually be here
64

7-
def create_user(db: Session, username: str, password: str):
8-
# Check if user already exists
9-
existing_user = repository.get_user_by_username(db, username)
5+
def create_user(repo: repository.UserRepository, username: str, password: str):
6+
existing_user = repo.get_user_by_username(username)
107
if existing_user:
118
raise HTTPException(
129
status_code=status.HTTP_400_BAD_REQUEST,
1310
detail="Username already registered"
1411
)
12+
return repo.create_user(username, password)
1513

16-
# Create new user
17-
return repository.create_user(db, username, password)
1814

19-
20-
def authenticate_user(db: Session, username: str, password: str):
21-
# Get user from database
22-
user = repository.get_user_by_username(db, username)
23-
24-
# Check if user exists and password is correct
25-
if not user or not repository.verify_password(password, user.hashed_password):
15+
def authenticate_user(repo: repository.UserRepository, username: str, password: str):
16+
user = repo.get_user_by_username(username)
17+
if not user or not repo.verify_password(password, user.hashed_password):
2618
raise HTTPException(
2719
status_code=status.HTTP_401_UNAUTHORIZED,
2820
detail="Incorrect username or password",
2921
headers={"WWW-Authenticate": "Bearer"},
3022
)
31-
3223
return user

0 commit comments

Comments
 (0)