Added full suite of tests & added testing to CI/CD

This commit is contained in:
c-d-p
2025-04-23 00:51:14 +02:00
parent e15a5c7612
commit be00f021ba
27 changed files with 1035 additions and 48 deletions

View File

@@ -19,8 +19,66 @@ on:
- cron: '0 3 * * 0'
jobs:
build-and-deploy:
# ========================================================================
# Job to run unit tests.
# ========================================================================
test:
name: Run Linters and Tests
runs-on: ubuntu-latest
steps:
# Checks out the repo under $GITHUB_WORKSPACE
- name: Checkout code
uses: actions/checkout@v4
# Sets up Python 3.12 environment
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.12'
# Cache pip dependencies for faster reruns
- name: Cache pip dependencies
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
working-directory: ./backend
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Lint with Ruff
working-directory: ./backend
run: |
ruff check .
- name: Check formatting with Black
working-directory: ./backend
run: |
black --check .
- name: Run Pytest
working-directory: ./backend
run: |
pytest
# ========================================================================
# Job to build and deploy the Docker image to mara.
# ========================================================================
build-and-deploy:
name: Build and Deploy
runs-on: ubuntu-latest
needs: test # Ensure tests pass before deploying
# Only run this job if triggered by a push to main or manual dispatch/schedule
# This prevents it running for PRs (eventually)
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
steps:
# Checks out the repo under $GITHUB_WORKSPACE
- name: Checkout code

7
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"python.testing.pytestArgs": [
"backend"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}

View File

@@ -3,12 +3,14 @@ from pydantic_settings import BaseSettings
from pydantic import Field # Import Field for potential default values if needed
import os
DOTENV_PATH = os.path.join(os.path.dirname(__file__), "../.env")
class Settings(BaseSettings):
# Database settings - reads DB_URL from environment or .env
DB_URL: str
DB_URL: str = "postgresql://maia:maia@localhost:5432/maia"
# Redis settings - reads REDIS_URL from environment or .env, also used for Celery.
REDIS_URL: str
REDIS_URL: str ="redis://localhost:6379/0"
# JWT settings - reads from environment or .env
JWT_ALGORITHM: str = "HS256"
@@ -22,7 +24,7 @@ class Settings(BaseSettings):
class Config:
# Tell pydantic-settings to load variables from a .env file
env_file = '.env'
env_file = DOTENV_PATH
env_file_encoding = 'utf-8'
extra = 'ignore'

View File

@@ -1,5 +1,5 @@
# modules/calendar/api.py
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from datetime import datetime
from typing import List, Optional
@@ -12,7 +12,7 @@ from modules.calendar.service import create_calendar_event, get_calendar_event_b
router = APIRouter(prefix="/calendar", tags=["calendar"])
@router.post("/events", response_model=CalendarEventResponse)
@router.post("/events", response_model=CalendarEventResponse, status_code=status.HTTP_201_CREATED)
def create_event(
event: CalendarEventCreate,
user: User = Depends(get_current_user),

View File

@@ -1,5 +1,5 @@
# modules/calendar/models.py
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, JSON # Add JSON
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, JSON, Boolean # Add Boolean
from sqlalchemy.orm import relationship
from core.database import Base
@@ -12,6 +12,7 @@ class CalendarEvent(Base):
start = Column(DateTime, nullable=False)
end = Column(DateTime)
location = Column(String)
all_day = Column(Boolean, default=False) # Add all_day column
tags = Column(JSON)
color = Column(String) # hex code for color
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) # <-- Relationship

View File

@@ -11,6 +11,7 @@ class CalendarEventBase(BaseModel):
end: Optional[datetime] = None
location: Optional[str] = None
color: Optional[str] = None # Assuming color exists
all_day: Optional[bool] = None # Add all_day field
tags: Optional[List[str]] = None # Add optional tags
@field_validator('tags', mode='before')
@@ -32,6 +33,7 @@ class CalendarEventUpdate(BaseModel):
end: Optional[datetime] = None
location: Optional[str] = None
color: Optional[str] = None
all_day: Optional[bool] = None # Add all_day field
tags: Optional[List[str]] = None # Add optional tags for update
@field_validator('tags', mode='before')

View File

@@ -0,0 +1,4 @@
pytest
pytest-cov # For checking test coverage (optional)
ruff # Or flake8, pylint etc. for linting
black # For code formatting checks

View File

@@ -8,13 +8,14 @@ from modules.auth.models import User
from modules.auth.security import authenticate_user, create_access_token, create_refresh_token, hash_password
from modules.auth.schemas import UserRole
from tests.conftest import fake
from typing import Optional # Import Optional
def create_user(db: Session, is_admin: bool = False) -> User:
def create_user(db: Session, is_admin: bool = False, username: Optional[str] = None) -> User:
unhashed_password = fake.password()
_user = User(
name=fake.name(),
username=fake.user_name(),
username=username or fake.user_name(), # Use provided username or generate one
hashed_password=hash_password(unhashed_password),
uuid=uuid_pkg.uuid4(),
role=UserRole.ADMIN if is_admin else UserRole.USER,

View File

@@ -0,0 +1,79 @@
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from unittest.mock import patch
from tests.helpers import generators
from modules.auth.models import UserRole
# Test admin routes require admin privileges
def test_read_admin_unauthorized(client: TestClient) -> None:
"""Test accessing admin route without authentication."""
response = client.get("/api/admin/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_read_admin_forbidden(db: Session, client: TestClient) -> None:
"""Test accessing admin route as a non-admin user."""
user, password = generators.create_user(db, is_admin=False) # Use is_admin=False
login_rsp = generators.login(db, user.username, password)
access_token = login_rsp["access_token"]
response = client.get("/api/admin/", headers={"Authorization": f"Bearer {access_token}"})
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_read_admin_success(db: Session, client: TestClient) -> None:
"""Test accessing admin route as an admin user."""
admin_user, password = generators.create_user(db, is_admin=True) # Use is_admin=True
login_rsp = generators.login(db, admin_user.username, password)
access_token = login_rsp["access_token"]
response = client.get("/api/admin/", headers={"Authorization": f"Bearer {access_token}"})
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"message": "Admin route"}
@patch("modules.admin.api.cleardb.delay") # Mock the celery task
def test_clear_db_soft(mock_cleardb_delay, db: Session, client: TestClient) -> None:
"""Test soft clearing the database as admin."""
admin_user, password = generators.create_user(db, is_admin=True) # Use is_admin=True
login_rsp = generators.login(db, admin_user.username, password)
access_token = login_rsp["access_token"]
response = client.post(
"/api/admin/cleardb",
headers={"Authorization": f"Bearer {access_token}"},
json={"hard": False}
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"message": "Clearing database in the background", "hard": False}
mock_cleardb_delay.assert_called_once_with(False)
@patch("modules.admin.api.cleardb.delay") # Mock the celery task
def test_clear_db_hard(mock_cleardb_delay, db: Session, client: TestClient) -> None:
"""Test hard clearing the database as admin."""
admin_user, password = generators.create_user(db, is_admin=True) # Use is_admin=True
login_rsp = generators.login(db, admin_user.username, password)
access_token = login_rsp["access_token"]
response = client.post(
"/api/admin/cleardb",
headers={"Authorization": f"Bearer {access_token}"},
json={"hard": True}
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"message": "Clearing database in the background", "hard": True}
mock_cleardb_delay.assert_called_once_with(True)
def test_clear_db_forbidden(db: Session, client: TestClient) -> None:
"""Test clearing the database as a non-admin user."""
user, password = generators.create_user(db, is_admin=False) # Use is_admin=False
login_rsp = generators.login(db, user.username, password)
access_token = login_rsp["access_token"]
response = client.post(
"/api/admin/cleardb",
headers={"Authorization": f"Bearer {access_token}"},
json={"hard": False}
)
assert response.status_code == status.HTTP_403_FORBIDDEN

View File

@@ -61,8 +61,8 @@ def test_refresh_token(db: Session, client: TestClient) -> None:
response = client.post(
"/api/auth/refresh",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token},
headers={"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"},
json={"refresh_token": refresh_token},
)
assert response.status_code == status.HTTP_200_OK
@@ -80,8 +80,8 @@ def test_logout(db: Session, client: TestClient) -> None:
response = client.post(
"/api/auth/logout",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token},
headers={"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"},
json={"refresh_token": refresh_token},
)
assert response.status_code == status.HTTP_200_OK
@@ -98,7 +98,8 @@ def test_logout(db: Session, client: TestClient) -> None:
response = client.post(
"/api/auth/refresh",
cookies={"refresh_token": refresh_token},
headers={"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"},
json={"refresh_token": refresh_token},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -178,3 +179,52 @@ def test_delete_user(db: Session, client: TestClient) -> None:
deleted_user = db.query(User).filter(User.username == user.username).first()
assert deleted_user is None
def test_get_user_forbidden(db: Session, client: TestClient) -> None:
"""Test getting another user's profile (should be forbidden)."""
user1, password_user1 = generators.create_user(db, username="user1_get_forbidden")
user2, _ = generators.create_user(db, username="user2_get_forbidden")
# Log in as user1
login_rsp = generators.login(db, user1.username, password_user1)
access_token = login_rsp["access_token"]
# Try to get user2's profile
response = client.get(
f"/api/user/{user2.username}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_update_user_forbidden(db: Session, client: TestClient) -> None:
"""Test updating another user's profile (should be forbidden)."""
user1, password_user1 = generators.create_user(db, username="user1_update_forbidden")
user2, _ = generators.create_user(db, username="user2_update_forbidden")
new_name = fake.name()
# Log in as user1
login_rsp = generators.login(db, user1.username, password_user1)
access_token = login_rsp["access_token"]
# Try to update user2's profile
response = client.patch(
f"/api/user/{user2.username}",
headers={"Authorization": f"Bearer {access_token}"},
json={"name": new_name},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_delete_user_forbidden(db: Session, client: TestClient) -> None:
"""Test deleting another user's profile (should be forbidden)."""
user1, password_user1 = generators.create_user(db, username="user1_delete_forbidden")
user2, _ = generators.create_user(db, username="user2_delete_forbidden")
# Log in as user1
login_rsp = generators.login(db, user1.username, password_user1)
access_token = login_rsp["access_token"]
# Try to delete user2's profile
response = client.delete(
f"/api/user/{user2.username}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN

View File

@@ -1,44 +1,389 @@
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from tests.helpers import generators
from modules.calendar.models import CalendarEvent # Assuming model exists
from tests.conftest import fake
# Helper function to create an event payload
def create_event_payload(start_offset_days=0, end_offset_days=1):
start_time = datetime.utcnow() + timedelta(days=start_offset_days)
end_time = datetime.utcnow() + timedelta(days=end_offset_days)
return {
"title": fake.sentence(nb_words=3),
"description": fake.text(),
"start": start_time.isoformat(), # Rename start_time to start
"end": end_time.isoformat(), # Rename end_time to end
"all_day": fake.boolean(),
}
def test_create_event(client: TestClient, db: Session) -> None:
user, unhashed_password = generators.create_user(db)
rsp = generators.login(db, user.username, unhashed_password)
access_token = rsp["access_token"]
refresh_token = rsp["refresh_token"]
# --- Test Create Event ---
response = client.post("/api/calendar/events",
json={
"title": "Test Event",
"start_time": "2024-03-20T15:00:00Z"
},
def test_create_event_unauthorized(client: TestClient) -> None:
"""Test creating an event without authentication."""
payload = create_event_payload()
response = client.post("/api/calendar/events", json=payload)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_create_event_success(db: Session, client: TestClient) -> None:
"""Test creating a calendar event successfully."""
user, password = generators.create_user(db)
login_rsp = generators.login(db, user.username, password)
access_token = login_rsp["access_token"]
payload = create_event_payload()
response = client.post(
"/api/calendar/events",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token},
json=payload
)
assert response.status_code == 200
assert response.json()["title"] == "Test Event"
assert response.status_code == status.HTTP_201_CREATED # Change expected status to 201
data = response.json()
assert data["title"] == payload["title"]
assert data["description"] == payload["description"]
# Remove the '+ "Z"' as the API doesn't add it
assert data["start"] == payload["start"]
assert data["end"] == payload["end"]
assert data["all_day"] == payload["all_day"]
assert "id" in data
assert data["user_id"] == user.id
def test_get_events(client: TestClient, db: Session) -> None:
user, unhashed_password = generators.create_user(db)
rsp = generators.login(db, user.username, unhashed_password)
access_token = rsp["access_token"]
refresh_token = rsp["refresh_token"]
# Verify in DB
event_in_db = db.query(CalendarEvent).filter(CalendarEvent.id == data["id"]).first()
assert event_in_db is not None
assert event_in_db.user_id == user.id
assert event_in_db.title == payload["title"]
# Create an event to retrieve
client.post("/api/calendar/events",
json={
"title": "Test Event",
"start_time": "2024-03-20T15:00:00Z"
},
# --- Test Get Events ---
def test_get_events_unauthorized(client: TestClient) -> None:
"""Test getting events without authentication."""
response = client.get("/api/calendar/events")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_get_events_success(db: Session, client: TestClient) -> None:
"""Test getting all calendar events for a user."""
user, password = generators.create_user(db)
login_rsp = generators.login(db, user.username, password)
access_token = login_rsp["access_token"]
# Create a couple of events for the user
payload1 = create_event_payload(0, 1)
client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload1)
payload2 = create_event_payload(2, 3)
client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload2)
# Create an event for another user (should not be returned)
other_user, other_password = generators.create_user(db)
other_login_rsp = generators.login(db, other_user.username, other_password)
other_access_token = other_login_rsp["access_token"]
other_payload = create_event_payload(4, 5)
client.post("/api/calendar/events", headers={"Authorization": f"Bearer {other_access_token}"}, json=other_payload)
response = client.get(
"/api/calendar/events",
headers={"Authorization": f"Bearer {access_token}"}
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) == 2
assert data[0]["title"] == payload1["title"]
assert data[1]["title"] == payload2["title"]
assert data[0]["user_id"] == user.id
assert data[1]["user_id"] == user.id
def test_get_events_filtered(db: Session, client: TestClient) -> None:
"""Test getting filtered calendar events for a user."""
user, password = generators.create_user(db)
login_rsp = generators.login(db, user.username, password)
access_token = login_rsp["access_token"]
# Create events
payload1 = create_event_payload(0, 1) # Today -> Tomorrow
client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload1)
payload2 = create_event_payload(5, 6) # In 5 days -> In 6 days
client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload2)
payload3 = create_event_payload(10, 11) # In 10 days -> In 11 days
client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload3)
# Filter for events starting within the next week
start_filter = datetime.utcnow().isoformat()
end_filter = (datetime.utcnow() + timedelta(days=7)).isoformat()
response = client.get(
"/api/calendar/events",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token},
params={"start": start_filter, "end": end_filter}
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) == 2 # Should get event 1 and 2
assert data[0]["title"] == payload1["title"]
assert data[1]["title"] == payload2["title"]
response = client.get("/api/calendar/events",
# Filter for events starting after 8 days
start_filter_late = (datetime.utcnow() + timedelta(days=8)).isoformat()
response = client.get(
"/api/calendar/events",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token},
params={"start": start_filter_late}
)
assert response.status_code == 200
assert len(response.json()) > 0
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) == 1 # Should get event 3
assert data[0]["title"] == payload3["title"]
# --- Test Get Event By ID ---
def test_get_event_by_id_unauthorized(db: Session, client: TestClient) -> None:
"""Test getting a specific event without authentication."""
user, password = generators.create_user(db)
login_rsp = generators.login(db, user.username, password)
access_token = login_rsp["access_token"]
payload = create_event_payload()
create_response = client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload)
event_id = create_response.json()["id"]
response = client.get(f"/api/calendar/events/{event_id}")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_get_event_by_id_success(db: Session, client: TestClient) -> None:
"""Test getting a specific event successfully."""
user, password = generators.create_user(db)
login_rsp = generators.login(db, user.username, password)
access_token = login_rsp["access_token"]
payload = create_event_payload()
create_response = client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload)
event_id = create_response.json()["id"]
response = client.get(
f"/api/calendar/events/{event_id}",
headers={"Authorization": f"Bearer {access_token}"}
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == event_id
assert data["title"] == payload["title"]
assert data["user_id"] == user.id
def test_get_event_by_id_not_found(db: Session, client: TestClient) -> None:
"""Test getting a non-existent event."""
user, password = generators.create_user(db)
login_rsp = generators.login(db, user.username, password)
access_token = login_rsp["access_token"]
non_existent_id = 99999
response = client.get(
f"/api/calendar/events/{non_existent_id}",
headers={"Authorization": f"Bearer {access_token}"}
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_get_event_by_id_forbidden(db: Session, client: TestClient) -> None:
"""Test getting another user's event."""
user1, password_user1 = generators.create_user(db)
user2, password_user2 = generators.create_user(db)
# Log in as user1 and create an event
login_rsp1 = generators.login(db, user1.username, password_user1)
access_token1 = login_rsp1["access_token"]
payload = create_event_payload()
create_response = client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token1}"}, json=payload)
event_id = create_response.json()["id"]
# Log in as user2 and try to get user1's event
login_rsp2 = generators.login(db, user2.username, password_user2)
access_token2 = login_rsp2["access_token"]
response = client.get(
f"/api/calendar/events/{event_id}",
headers={"Authorization": f"Bearer {access_token2}"}
)
assert response.status_code == status.HTTP_404_NOT_FOUND # Service layer returns 404 if user_id doesn't match
# --- Test Update Event ---
def test_update_event_unauthorized(db: Session, client: TestClient) -> None:
"""Test updating an event without authentication."""
user, password = generators.create_user(db)
login_rsp = generators.login(db, user.username, password)
access_token = login_rsp["access_token"]
payload = create_event_payload()
create_response = client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload)
event_id = create_response.json()["id"]
update_payload = {"title": "Updated Title"}
response = client.patch(f"/api/calendar/events/{event_id}", json=update_payload)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_update_event_success(db: Session, client: TestClient) -> None:
"""Test updating an event successfully."""
user, password = generators.create_user(db)
login_rsp = generators.login(db, user.username, password)
access_token = login_rsp["access_token"]
payload = create_event_payload()
create_response = client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload)
assert create_response.status_code == status.HTTP_201_CREATED # Ensure creation check uses 201
event_id = create_response.json()["id"]
update_payload = {
"title": "Updated Title",
"description": "Updated description.",
"all_day": not payload["all_day"] # Toggle all_day
}
response = client.patch(
f"/api/calendar/events/{event_id}",
headers={"Authorization": f"Bearer {access_token}"},
json=update_payload
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == event_id
assert data["title"] == update_payload["title"]
assert data["description"] == update_payload["description"]
assert data["all_day"] == update_payload["all_day"]
assert data["start"] == payload["start"] # Check correct field name 'start'
assert data["user_id"] == user.id
# Verify in DB
event_in_db = db.query(CalendarEvent).filter(CalendarEvent.id == event_id).first()
assert event_in_db is not None
assert event_in_db.title == update_payload["title"]
assert event_in_db.description == update_payload["description"]
assert event_in_db.all_day == update_payload["all_day"]
def test_update_event_not_found(db: Session, client: TestClient) -> None:
"""Test updating a non-existent event."""
user, password = generators.create_user(db)
login_rsp = generators.login(db, user.username, password)
access_token = login_rsp["access_token"]
non_existent_id = 99999
update_payload = {"title": "Updated Title"}
response = client.patch(
f"/api/calendar/events/{non_existent_id}",
headers={"Authorization": f"Bearer {access_token}"},
json=update_payload
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_update_event_forbidden(db: Session, client: TestClient) -> None:
"""Test updating another user's event."""
user1, password_user1 = generators.create_user(db)
user2, password_user2 = generators.create_user(db)
# Log in as user1 and create an event
login_rsp1 = generators.login(db, user1.username, password_user1)
access_token1 = login_rsp1["access_token"]
payload = create_event_payload()
create_response = client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token1}"}, json=payload)
event_id = create_response.json()["id"]
# Log in as user2 and try to update user1's event
login_rsp2 = generators.login(db, user2.username, password_user2)
access_token2 = login_rsp2["access_token"]
update_payload = {"title": "Updated by User 2"}
response = client.patch(
f"/api/calendar/events/{event_id}",
headers={"Authorization": f"Bearer {access_token2}"},
json=update_payload
)
assert response.status_code == status.HTTP_404_NOT_FOUND # Service layer returns 404 if user_id doesn't match
# --- Test Delete Event ---
def test_delete_event_unauthorized(db: Session, client: TestClient) -> None:
"""Test deleting an event without authentication."""
user, password = generators.create_user(db)
login_rsp = generators.login(db, user.username, password)
access_token = login_rsp["access_token"]
payload = create_event_payload()
create_response = client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload)
event_id = create_response.json()["id"]
response = client.delete(f"/api/calendar/events/{event_id}")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_delete_event_success(db: Session, client: TestClient) -> None:
"""Test deleting an event successfully."""
user, password = generators.create_user(db)
login_rsp = generators.login(db, user.username, password)
access_token = login_rsp["access_token"]
payload = create_event_payload()
create_response = client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token}"}, json=payload)
assert create_response.status_code == status.HTTP_201_CREATED # Ensure creation check uses 201
event_id = create_response.json()["id"]
# Verify event exists before delete
event_in_db = db.query(CalendarEvent).filter(CalendarEvent.id == event_id).first()
assert event_in_db is not None
response = client.delete(
f"/api/calendar/events/{event_id}",
headers={"Authorization": f"Bearer {access_token}"}
)
assert response.status_code == status.HTTP_204_NO_CONTENT
# Verify event is deleted from DB
event_in_db = db.query(CalendarEvent).filter(CalendarEvent.id == event_id).first()
assert event_in_db is None
# Try getting the deleted event (should be 404)
get_response = client.get(
f"/api/calendar/events/{event_id}",
headers={"Authorization": f"Bearer {access_token}"}
)
assert get_response.status_code == status.HTTP_404_NOT_FOUND
def test_delete_event_not_found(db: Session, client: TestClient) -> None:
"""Test deleting a non-existent event."""
user, password = generators.create_user(db)
login_rsp = generators.login(db, user.username, password)
access_token = login_rsp["access_token"]
non_existent_id = 99999
response = client.delete(
f"/api/calendar/events/{non_existent_id}",
headers={"Authorization": f"Bearer {access_token}"}
)
# The service layer raises NotFound, which should result in 404
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_delete_event_forbidden(db: Session, client: TestClient) -> None:
"""Test deleting another user's event."""
user1, password_user1 = generators.create_user(db)
user2, password_user2 = generators.create_user(db)
# Log in as user1 and create an event
login_rsp1 = generators.login(db, user1.username, password_user1)
access_token1 = login_rsp1["access_token"]
payload = create_event_payload()
create_response = client.post("/api/calendar/events", headers={"Authorization": f"Bearer {access_token1}"}, json=payload)
event_id = create_response.json()["id"]
# Log in as user2 and try to delete user1's event
login_rsp2 = generators.login(db, user2.username, password_user2)
access_token2 = login_rsp2["access_token"]
response = client.delete(
f"/api/calendar/events/{event_id}",
headers={"Authorization": f"Bearer {access_token2}"}
)
# The service layer raises NotFound if user_id doesn't match, resulting in 404
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify event still exists for user1
event_in_db = db.query(CalendarEvent).filter(CalendarEvent.id == event_id).first()
assert event_in_db is not None
assert event_in_db.user_id == user1.id

View File

@@ -0,0 +1,10 @@
import pytest
from fastapi.testclient import TestClient
# No database needed for this simple test
def test_health_check(client: TestClient):
"""Test the health check endpoint."""
response = client.get("/api/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}

218
backend/tests/test_nlp.py Normal file
View File

@@ -0,0 +1,218 @@
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from unittest.mock import patch, MagicMock
from datetime import datetime
from tests.helpers import generators
from modules.nlp.schemas import ProcessCommandRequest, ProcessCommandResponse
from modules.nlp.models import MessageSender, ChatMessage # Import necessary models/enums
# --- Mocks ---
# Mock the external AI call and internal service functions
@pytest.fixture(autouse=True)
def mock_nlp_services():
with patch("modules.nlp.api.process_request") as mock_process, \
patch("modules.nlp.api.ask_ai") as mock_ask, \
patch("modules.nlp.api.save_chat_message") as mock_save, \
patch("modules.nlp.api.get_chat_history") as mock_get_history, \
patch("modules.nlp.api.create_calendar_event") as mock_create_event, \
patch("modules.nlp.api.get_calendar_events") as mock_get_events, \
patch("modules.nlp.api.update_calendar_event") as mock_update_event, \
patch("modules.nlp.api.delete_calendar_event") as mock_delete_event, \
patch("modules.nlp.api.todo_service.create_todo") as mock_create_todo, \
patch("modules.nlp.api.todo_service.get_todos") as mock_get_todos, \
patch("modules.nlp.api.todo_service.update_todo") as mock_update_todo, \
patch("modules.nlp.api.todo_service.delete_todo") as mock_delete_todo:
mocks = {
"process_request": mock_process,
"ask_ai": mock_ask,
"save_chat_message": mock_save,
"get_chat_history": mock_get_history,
"create_calendar_event": mock_create_event,
"get_calendar_events": mock_get_events,
"update_calendar_event": mock_update_event,
"delete_calendar_event": mock_delete_event,
"create_todo": mock_create_todo,
"get_todos": mock_get_todos,
"update_todo": mock_update_todo,
"delete_todo": mock_delete_todo,
}
yield mocks
# --- Helper Function ---
def _login_user(db: Session, client: TestClient):
user, password = generators.create_user(db)
login_rsp = generators.login(db, user.username, password)
return user, login_rsp["access_token"], login_rsp["refresh_token"]
# --- Tests for /process-command ---
def test_process_command_ask_ai(client: TestClient, db: Session, mock_nlp_services):
user, access_token, refresh_token = _login_user(db, client)
user_input = "What is the capital of France?"
mock_nlp_services["process_request"].return_value = {
"intent": "ask_ai",
"params": {"request": user_input},
"response_text": "Let me check that for you."
}
mock_nlp_services["ask_ai"].return_value = "The capital of France is Paris."
response = client.post(
"/api/nlp/process-command",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token},
json={"user_input": user_input}
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == ProcessCommandResponse(responses=["Let me check that for you.", "The capital of France is Paris."]).model_dump()
# Verify save calls: user message, initial AI response, final AI answer
assert mock_nlp_services["save_chat_message"].call_count == 3
mock_nlp_services["save_chat_message"].assert_any_call(db, user_id=user.id, sender=MessageSender.USER, text=user_input)
mock_nlp_services["save_chat_message"].assert_any_call(db, user_id=user.id, sender=MessageSender.AI, text="Let me check that for you.")
mock_nlp_services["save_chat_message"].assert_any_call(db, user_id=user.id, sender=MessageSender.AI, text="The capital of France is Paris.")
mock_nlp_services["ask_ai"].assert_called_once_with(request=user_input)
def test_process_command_get_calendar(client: TestClient, db: Session, mock_nlp_services):
user, access_token, refresh_token = _login_user(db, client)
user_input = "What are my events today?"
mock_nlp_services["process_request"].return_value = {
"intent": "get_calendar_events",
"params": {"start": "2024-01-01T00:00:00Z", "end": "2024-01-01T23:59:59Z"}, # Example params
"response_text": "Okay, fetching your events."
}
# Mock the actual event model returned by the service
mock_event = MagicMock()
mock_event.title = "Team Meeting"
mock_event.start = datetime(2024, 1, 1, 10, 0, 0)
mock_event.end = datetime(2024, 1, 1, 11, 0, 0)
mock_nlp_services["get_calendar_events"].return_value = [mock_event]
response = client.post(
"/api/nlp/process-command",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token},
json={"user_input": user_input}
)
assert response.status_code == status.HTTP_200_OK
expected_responses = [
"Okay, fetching your events.",
"Here are the events:",
"- Team Meeting (2024-01-01 10:00 - 11:00)"
]
assert response.json() == ProcessCommandResponse(responses=expected_responses).model_dump()
assert mock_nlp_services["save_chat_message"].call_count == 4 # User, Initial AI, Header, Event
mock_nlp_services["get_calendar_events"].assert_called_once()
def test_process_command_add_todo(client: TestClient, db: Session, mock_nlp_services):
user, access_token, refresh_token = _login_user(db, client)
user_input = "Add buy milk to my list"
mock_nlp_services["process_request"].return_value = {
"intent": "add_todo",
"params": {"task": "buy milk"},
"response_text": "Adding it now."
}
# Mock the actual Todo model returned by the service
mock_todo = MagicMock()
mock_todo.task = "buy milk"
mock_todo.id = 1
mock_nlp_services["create_todo"].return_value = mock_todo
response = client.post(
"/api/nlp/process-command",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token},
json={"user_input": user_input}
)
assert response.status_code == status.HTTP_200_OK
expected_responses = ["Adding it now.", "Added TODO: 'buy milk' (ID: 1)."]
assert response.json() == ProcessCommandResponse(responses=expected_responses).model_dump()
assert mock_nlp_services["save_chat_message"].call_count == 3 # User, Initial AI, Confirmation AI
mock_nlp_services["create_todo"].assert_called_once()
def test_process_command_clarification(client: TestClient, db: Session, mock_nlp_services):
user, access_token, refresh_token = _login_user(db, client)
user_input = "Delete the event"
clarification_text = "Which event do you mean? Please provide the ID."
mock_nlp_services["process_request"].return_value = {
"intent": "clarification_needed",
"params": {"request": user_input},
"response_text": clarification_text
}
response = client.post(
"/api/nlp/process-command",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token},
json={"user_input": user_input}
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == ProcessCommandResponse(responses=[clarification_text]).model_dump()
# Verify save calls: user message, clarification AI response
assert mock_nlp_services["save_chat_message"].call_count == 2
mock_nlp_services["save_chat_message"].assert_any_call(db, user_id=user.id, sender=MessageSender.USER, text=user_input)
mock_nlp_services["save_chat_message"].assert_any_call(db, user_id=user.id, sender=MessageSender.AI, text=clarification_text)
# Ensure no action services were called
mock_nlp_services["delete_calendar_event"].assert_not_called()
def test_process_command_error_intent(client: TestClient, db: Session, mock_nlp_services):
user, access_token, refresh_token = _login_user(db, client)
user_input = "Gibberish request"
error_text = "Sorry, I didn't understand that."
mock_nlp_services["process_request"].return_value = {
"intent": "error",
"params": {},
"response_text": error_text
}
response = client.post(
"/api/nlp/process-command",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token},
json={"user_input": user_input}
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == ProcessCommandResponse(responses=[error_text]).model_dump()
# Verify save calls: user message, error AI response
assert mock_nlp_services["save_chat_message"].call_count == 2
mock_nlp_services["save_chat_message"].assert_any_call(db, user_id=user.id, sender=MessageSender.USER, text=user_input)
mock_nlp_services["save_chat_message"].assert_any_call(db, user_id=user.id, sender=MessageSender.AI, text=error_text)
# --- Tests for /history ---
def test_get_history(client: TestClient, db: Session, mock_nlp_services):
user, access_token, refresh_token = _login_user(db, client)
# Mock the history data returned by the service
mock_history = [
ChatMessage(id=1, user_id=user.id, sender=MessageSender.USER, text="Hello", timestamp=datetime.now()),
ChatMessage(id=2, user_id=user.id, sender=MessageSender.AI, text="Hi there!", timestamp=datetime.now())
]
mock_nlp_services["get_chat_history"].return_value = mock_history
response = client.get(
"/api/nlp/history",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token}
)
assert response.status_code == status.HTTP_200_OK
# We need to compare JSON representations as datetime objects might differ slightly
response_data = response.json()
assert len(response_data) == 2
assert response_data[0]["text"] == "Hello"
assert response_data[1]["text"] == "Hi there!"
mock_nlp_services["get_chat_history"].assert_called_once_with(db, user_id=user.id, limit=50)
def test_get_history_unauthorized(client: TestClient):
response = client.get("/api/nlp/history")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# Add more tests for other intents (update/delete calendar/todo, unknown intent, etc.)
# Add tests for error handling within the API endpoint (e.g., missing IDs for update/delete)

210
backend/tests/test_todo.py Normal file
View File

@@ -0,0 +1,210 @@
import pytest
from fastapi import status
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from datetime import date
from tests.helpers import generators
from modules.todo import schemas # Import schemas
# Helper Function
def _login_user(db: Session, client: TestClient):
user, password = generators.create_user(db)
login_rsp = generators.login(db, user.username, password)
return user, login_rsp["access_token"], login_rsp["refresh_token"]
# --- Test CRUD Operations ---
def test_create_todo(client: TestClient, db: Session):
user, access_token, refresh_token = _login_user(db, client)
today_date = date.today()
# Format the date string to match the expected response format "YYYY-MM-DDTHH:MM:SS"
todo_data = {
"task": "Test TODO",
"date": f"{today_date.isoformat()}T00:00:00",
"remind": True
}
response = client.post(
"/api/todos/",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token},
json=todo_data
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["task"] == todo_data["task"]
assert data["date"] == todo_data["date"]
assert data["remind"] == todo_data["remind"]
assert data["complete"] is False # Default
assert "id" in data
assert data["owner_id"] == user.id
def test_read_todos(client: TestClient, db: Session):
user, access_token, refresh_token = _login_user(db, client)
# Create some todos for the user
client.post("/api/todos/", headers={"Authorization": f"Bearer {access_token}"}, cookies={"refresh_token": refresh_token}, json={"task": "Todo 1"})
client.post("/api/todos/", headers={"Authorization": f"Bearer {access_token}"}, cookies={"refresh_token": refresh_token}, json={"task": "Todo 2"})
# Create a todo for another user
other_user, other_password = generators.create_user(db)
other_login_rsp = generators.login(db, other_user.username, other_password)
other_access_token = other_login_rsp["access_token"]
other_refresh_token = other_login_rsp["refresh_token"]
client.post("/api/todos/", headers={"Authorization": f"Bearer {other_access_token}"}, cookies={"refresh_token": other_refresh_token}, json={"task": "Other User Todo"})
response = client.get(
"/api/todos/",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token}
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) == 2 # Should only get todos for the logged-in user
assert data[0]["task"] == "Todo 1"
assert data[1]["task"] == "Todo 2"
def test_read_single_todo(client: TestClient, db: Session):
user, access_token, refresh_token = _login_user(db, client)
create_response = client.post(
"/api/todos/",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token},
json={"task": "Specific Todo"}
)
todo_id = create_response.json()["id"]
response = client.get(
f"/api/todos/{todo_id}",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token}
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == todo_id
assert data["task"] == "Specific Todo"
assert data["owner_id"] == user.id
def test_read_single_todo_not_found(client: TestClient, db: Session):
user, access_token, refresh_token = _login_user(db, client)
response = client.get(
"/api/todos/9999", # Non-existent ID
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token}
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_read_single_todo_forbidden(client: TestClient, db: Session):
user, access_token, refresh_token = _login_user(db, client)
# Create a todo for another user
other_user, other_password = generators.create_user(db)
other_login_rsp = generators.login(db, other_user.username, other_password)
other_access_token = other_login_rsp["access_token"]
other_refresh_token = other_login_rsp["refresh_token"]
other_create_response = client.post("/api/todos/", headers={"Authorization": f"Bearer {other_access_token}"}, cookies={"refresh_token": other_refresh_token}, json={"task": "Other User Todo"})
other_todo_id = other_create_response.json()["id"]
# Try to access the other user's todo
response = client.get(
f"/api/todos/{other_todo_id}",
headers={"Authorization": f"Bearer {access_token}"}, # Using the first user's token
cookies={"refresh_token": refresh_token}
)
assert response.status_code == status.HTTP_404_NOT_FOUND # Service raises 404 if not found for *this* user
def test_update_todo(client: TestClient, db: Session):
user, access_token, refresh_token = _login_user(db, client)
create_response = client.post(
"/api/todos/",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token},
json={"task": "Update Me"}
)
todo_id = create_response.json()["id"]
update_data = {"task": "Updated Task", "complete": True}
response = client.put(
f"/api/todos/{todo_id}",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token},
json=update_data
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == todo_id
assert data["task"] == update_data["task"]
assert data["complete"] == update_data["complete"]
assert data["owner_id"] == user.id
# Verify update by reading again
get_response = client.get(
f"/api/todos/{todo_id}",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token}
)
assert get_response.json()["task"] == update_data["task"]
assert get_response.json()["complete"] == update_data["complete"]
def test_update_todo_not_found(client: TestClient, db: Session):
user, access_token, refresh_token = _login_user(db, client)
update_data = {"task": "Updated Task", "complete": True}
response = client.put(
"/api/todos/9999", # Non-existent ID
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token},
json=update_data
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_delete_todo(client: TestClient, db: Session):
user, access_token, refresh_token = _login_user(db, client)
create_response = client.post(
"/api/todos/",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token},
json={"task": "Delete Me"}
)
todo_id = create_response.json()["id"]
response = client.delete(
f"/api/todos/{todo_id}",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token}
)
assert response.status_code == status.HTTP_200_OK # Delete returns the deleted item
assert response.json()["id"] == todo_id
# Verify deletion by trying to read
get_response = client.get(
f"/api/todos/{todo_id}",
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token}
)
assert get_response.status_code == status.HTTP_404_NOT_FOUND
def test_delete_todo_not_found(client: TestClient, db: Session):
user, access_token, refresh_token = _login_user(db, client)
response = client.delete(
"/api/todos/9999", # Non-existent ID
headers={"Authorization": f"Bearer {access_token}"},
cookies={"refresh_token": refresh_token}
)
assert response.status_code == status.HTTP_404_NOT_FOUND
# --- Test Authentication/Authorization ---
def test_create_todo_unauthorized(client: TestClient):
response = client.post("/api/todos/", json={"task": "No Auth"})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_read_todos_unauthorized(client: TestClient):
response = client.get("/api/todos/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED