Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 31 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Config file for GitHub Actions

name: Backend Tests

on: [push, pull_request]

jobs:
test:
runs-on: ubuntu-latest # Fastest option

steps:
- name: Checkout code
uses: actions/checkout@v6 # Copies code on VM

- name: Set up Python
uses: actions/setup-python@v5 # Official GitHub tool
with:
python-version: '3.12'

- name: Install dependencies
run: | # Everything below will be treated as one line
python -m pip install --upgrade pip
pip install -r backend/requirements.txt
pip install pytest-cov httpx

- name: Run tests
env:
PYTHONPATH: ${{ github.workspace }}/backend # github.workspace is a root dir, safe config
run: |
cd backend
pytest --cov=app --cov-report=term-missing
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ coverage.xml
# Unit test / pytest
# =========================
.pytest_cache/
test_api.db

# =========================
# Environment files
Expand Down
34 changes: 21 additions & 13 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,25 @@
from typing import List
from dotenv import load_dotenv
from fastapi.middleware.cors import CORSMiddleware
from datetime import datetime, timezone

# Automatic db creation
from contextlib import asynccontextmanager
from db.base import Base, engine
Base.metadata.create_all(bind=engine)


@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Funtion gets executed during 'uvicorn main:app command - server start'

lifespan needs a task, yield and optional task after yield
"""
Base.metadata.create_all(bind=engine)
yield

# Async functions need async sessions

load_dotenv()

app = FastAPI()
app = FastAPI(lifespan=lifespan)

origins = [
"http://localhost:5173"
Expand All @@ -33,13 +41,13 @@


@app.get('/')
async def index():
def index():
return {"message": "App is working"}


@app.get('/reminders', response_model=List[ReminderRead], description="Returns all reminders in the app")
# Get all reminders
async def all_reminders(db: Session = Depends(get_db)):
def all_reminders(db: Session = Depends(get_db)):
reminders = crud.get_all_reminders(db)
if not reminders:
return []
Expand All @@ -48,11 +56,11 @@ async def all_reminders(db: Session = Depends(get_db)):

@app.post('/create', response_model=ReminderRead, description="Create reminder")
# Create reminder
async def create_reminder(
def create_reminder(
reminder: ReminderCreate,
db: Session = Depends(get_db)
):

new_reminder = crud.create_reminder(
db,
title=reminder.title,
Expand All @@ -66,7 +74,7 @@ async def create_reminder(

@app.get('/reminders/{id}', response_model=ReminderRead, description="Get reminders by its ID")
# Get specific reminder
async def get_reminder(id: int, db: Session = Depends(get_db)):
def get_reminder(id: int, db: Session = Depends(get_db)):
reminder = crud.get_reminder(db, id)
if not reminder:
raise HTTPException(status_code=404, detail="Object was not found")
Expand All @@ -75,7 +83,7 @@ async def get_reminder(id: int, db: Session = Depends(get_db)):

@app.put('/reminders/{id}', response_model=ReminderRead, description="Edit reminder")
# Update reminder values
async def update_reminder(
def update_reminder(
id: int,
reminder: ReminderUpdate,
db: Session = Depends(get_db)
Expand All @@ -90,7 +98,7 @@ async def update_reminder(

@app.delete('/reminders/{id}', description="Delete reminder by its ID")
# Delete reminder
async def delete_reminder(id: int, db: Session = Depends(get_db)):
def delete_reminder(id: int, db: Session = Depends(get_db)):
deleted = crud.delete_reminder(db, id)
if not deleted:
raise HTTPException(status_code=404, detail="Object was not found")
Expand All @@ -100,7 +108,7 @@ async def delete_reminder(id: int, db: Session = Depends(get_db)):

@app.delete('/reminders', description="Delete all reminders")
# Delete all reminders
async def delete_all_reminders(db: Session = Depends(get_db)):
def delete_all_reminders(db: Session = Depends(get_db)):
deleted_reminders = crud.delete_all(db)
if not deleted_reminders:
return {'message': 'No reminders to delete'}
Expand Down
Binary file modified backend/celerybeat-schedule
Binary file not shown.
2 changes: 1 addition & 1 deletion backend/db/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@

engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) # If not False there would be an error while doing multiple threads
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
Base = declarative_base()
Binary file modified backend/db/test.db
Binary file not shown.
17 changes: 17 additions & 0 deletions backend/pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Config file for pytest

[tool:pytest] # Header for pytest
pythonpath = backend # Location of app
testpaths = backend/tests # Shows tests folder
python_files = test_*.py # Files only that starts with test_
python_functions = test_* # Only functions that start with test_
# Additional options
addopts =
-v # verbose - more details while running tests
--strict-markers # Python will alert about using unregistered marker
--tb=short # traceback for error, short version

# Section for registering markers
markers =
crud: operations on database (Create, Read, Update, Delete)
validation: validation tests for Pydantic and wrong data
Empty file added backend/tests/__init__.py
Empty file.
66 changes: 66 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import pytest
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy import create_engine
from db.base import Base
from fastapi.testclient import TestClient
from app.main import app
from db.deps import get_db

# After running pytest this file is saved in memory. In case of any 'client' mentions it checkes the name here - Dependency Injection

# Database for testing
TEST_DATABASE_URL = "sqlite:///./test_api.db"


@pytest.fixture # Pytest prepare for other functions
def test_db() -> Session:
'''
Creates clean database for each test.
After completing database is removed.
'''

engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False}) # Create engine in RAM

from db.models import Reminder # noqa: F401, import for Base

Base.metadata.create_all(bind=engine) # Creating db

TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) # Connections factory
db = TestingSessionLocal()

try:
yield db
finally:
db.close()
Base.metadata.drop_all(bind=engine) # Clear after test


@pytest.fixture
def client(test_db: Session) -> TestClient:
"""
TestClient FastAPI with test database.
Used to test endpoints, simulates user's action.
"""
def override_get_db():
try:
yield test_db
finally:
pass

app.dependency_overrides[get_db] = override_get_db # Switching database to test unit

with TestClient(app) as c:
yield c # Creating client for an action

app.dependency_overrides.clear() # Switching back to database


@pytest.fixture
def reminder_payload():
return {
"title": 'Laundry',
"description": "Remember about laundry",
"due_to": "24-12-2026 12:00",
"email": "test@example.com",
"alert_type": "minutes"
}
130 changes: 130 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
from fastapi.testclient import TestClient
import pytest


@pytest.mark.crud
def test_create_reminder(client: TestClient, reminder_payload):
"""
Creating reminder, id should appera in data
"""
response = client.post("/create", json=reminder_payload)

assert response.status_code == 200
data = response.json()
assert data['title'] == "Laundry"
assert "id" in data


@pytest.mark.crud
def test_get_reminder(client: TestClient, reminder_payload):
"""
Get one reminder, values should be matching
"""
response = client.post("/create", json=reminder_payload)
assert response.status_code == 200
data = response.json()

response_get = client.get(f"/reminders/{data.get('id')}")

assert response_get.status_code == 200
data_get = response_get.json()
assert data_get['title'] == reminder_payload['title']
assert data_get['description'] == reminder_payload['description']
assert data_get['email'] == reminder_payload['email']
assert data_get['alert_type'] == reminder_payload['alert_type']


@pytest.mark.crud
def test_delete_reminder_and_error(client: TestClient, reminder_payload):
"""
Tests deleting all reminders.
Aditionally checks status code for deleting non existing reminder
"""
response = client.post("/create", json=reminder_payload)
assert response.status_code == 200
data = response.json()
response_delete = client.delete(f"/reminders/{data['id']}")
assert response_delete.status_code == 200
response_delete_2 = client.delete(f"/reminders/{data['id']}")
assert response_delete_2.status_code == 404


@pytest.mark.crud
def test_put(client: TestClient, reminder_payload):
"""
Tests PUT for Reminder, should return status code 200 and new values
"""
response = client.post("/create", json=reminder_payload)
assert response.status_code == 200
data = response.json()

payload_put = {
"title": "Clean windows",
'description': "I've changed my mind"
}

response_put = client.put(f"/reminders/{data['id']}", json=payload_put)
assert response_put.status_code == 200

response_get = client.get(f"/reminders/{data['id']}")
data_get = response_get.json()

assert data_get['title'] == payload_put["title"]
assert data_get['description'] == payload_put['description']
assert data_get['email'] == reminder_payload['email']
assert data_get['alert_type'] == reminder_payload['alert_type']


@pytest.mark.validation
def test_wrong_date(client: TestClient):
"""
Tests providing wrong date format, should return status code 422 and ValueError
"""
payload = {
"title": 'Laundry',
"description": "Remember about laundry",
"due_to": "2026-12-42T12:00Z",
"email": "test@example.com",
"alert_type": "minutes"
}
response = client.post("/create", json=payload)
assert response.status_code == 422
assert "Field should be filled in dd-mm-yyyy hh:mm format" in response.text


@pytest.mark.crud
def test_all_reminders(client: TestClient, reminder_payload):
"""
Tests if getting all reminders works properly
"""
response = client.post("/create", json=reminder_payload)
assert response.status_code == 200
response2 = client.post("/create", json=reminder_payload)
assert response2.status_code == 200

response_get_all = client.get("/reminders")
assert response_get_all.status_code == 200
assert len(response_get_all.json()) == 2


@pytest.mark.crud
def test_delete_all(client: TestClient, reminder_payload):
"""
Test of deleting all reminders, should return empty list after.
Message returned if trying to delete empty list.
"""
response = client.post("/create", json=reminder_payload)
assert response.status_code == 200
response2 = client.post("/create", json=reminder_payload)
assert response2.status_code == 200

response_delete = client.delete("/reminders")
assert response_delete.status_code == 200

response_get_all = client.get("/reminders")
assert response_get_all.status_code == 200
assert response_get_all.json() == []

response_delete = client.delete("/reminders")
assert response_delete.status_code == 200
assert response_delete.json() == {'message': 'No reminders to delete'}