diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6381504..6777f4f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7581cbf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "backend" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/backend/core/__pycache__/celery_app.cpython-312.pyc b/backend/core/__pycache__/celery_app.cpython-312.pyc index 8919cbe..ed5aaec 100644 Binary files a/backend/core/__pycache__/celery_app.cpython-312.pyc and b/backend/core/__pycache__/celery_app.cpython-312.pyc differ diff --git a/backend/core/__pycache__/config.cpython-312.pyc b/backend/core/__pycache__/config.cpython-312.pyc index 3a175ae..1ae3b10 100644 Binary files a/backend/core/__pycache__/config.cpython-312.pyc and b/backend/core/__pycache__/config.cpython-312.pyc differ diff --git a/backend/core/config.py b/backend/core/config.py index 80c6211..05f3642 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -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' diff --git a/backend/modules/admin/__pycache__/api.cpython-312.pyc b/backend/modules/admin/__pycache__/api.cpython-312.pyc index e2753a4..06c11ba 100644 Binary files a/backend/modules/admin/__pycache__/api.cpython-312.pyc and b/backend/modules/admin/__pycache__/api.cpython-312.pyc differ diff --git a/backend/modules/calendar/__pycache__/api.cpython-312.pyc b/backend/modules/calendar/__pycache__/api.cpython-312.pyc index 1465036..b276969 100644 Binary files a/backend/modules/calendar/__pycache__/api.cpython-312.pyc and b/backend/modules/calendar/__pycache__/api.cpython-312.pyc differ diff --git a/backend/modules/calendar/__pycache__/models.cpython-312.pyc b/backend/modules/calendar/__pycache__/models.cpython-312.pyc index 31f3ae1..f86f1e9 100644 Binary files a/backend/modules/calendar/__pycache__/models.cpython-312.pyc and b/backend/modules/calendar/__pycache__/models.cpython-312.pyc differ diff --git a/backend/modules/calendar/__pycache__/schemas.cpython-312.pyc b/backend/modules/calendar/__pycache__/schemas.cpython-312.pyc index 829083a..cbfbd31 100644 Binary files a/backend/modules/calendar/__pycache__/schemas.cpython-312.pyc and b/backend/modules/calendar/__pycache__/schemas.cpython-312.pyc differ diff --git a/backend/modules/calendar/api.py b/backend/modules/calendar/api.py index bdd0700..81ecc15 100644 --- a/backend/modules/calendar/api.py +++ b/backend/modules/calendar/api.py @@ -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), diff --git a/backend/modules/calendar/models.py b/backend/modules/calendar/models.py index 9167a15..17cde49 100644 --- a/backend/modules/calendar/models.py +++ b/backend/modules/calendar/models.py @@ -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 diff --git a/backend/modules/calendar/schemas.py b/backend/modules/calendar/schemas.py index ed04949..760da49 100644 --- a/backend/modules/calendar/schemas.py +++ b/backend/modules/calendar/schemas.py @@ -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') diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..f991949 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,4 @@ +pytest +pytest-cov # For checking test coverage (optional) +ruff # Or flake8, pylint etc. for linting +black # For code formatting checks \ No newline at end of file diff --git a/backend/tests/__pycache__/test_admin.cpython-312-pytest-8.3.5.pyc b/backend/tests/__pycache__/test_admin.cpython-312-pytest-8.3.5.pyc index d8ac979..c7f94ca 100644 Binary files a/backend/tests/__pycache__/test_admin.cpython-312-pytest-8.3.5.pyc and b/backend/tests/__pycache__/test_admin.cpython-312-pytest-8.3.5.pyc differ diff --git a/backend/tests/__pycache__/test_auth.cpython-312-pytest-8.3.5.pyc b/backend/tests/__pycache__/test_auth.cpython-312-pytest-8.3.5.pyc index 9470be8..9d0b1dc 100644 Binary files a/backend/tests/__pycache__/test_auth.cpython-312-pytest-8.3.5.pyc and b/backend/tests/__pycache__/test_auth.cpython-312-pytest-8.3.5.pyc differ diff --git a/backend/tests/__pycache__/test_calendar.cpython-312-pytest-8.3.5.pyc b/backend/tests/__pycache__/test_calendar.cpython-312-pytest-8.3.5.pyc index c04a8ee..c5e6041 100644 Binary files a/backend/tests/__pycache__/test_calendar.cpython-312-pytest-8.3.5.pyc and b/backend/tests/__pycache__/test_calendar.cpython-312-pytest-8.3.5.pyc differ diff --git a/backend/tests/__pycache__/test_main.cpython-312-pytest-8.3.5.pyc b/backend/tests/__pycache__/test_main.cpython-312-pytest-8.3.5.pyc new file mode 100644 index 0000000..57e4dd2 Binary files /dev/null and b/backend/tests/__pycache__/test_main.cpython-312-pytest-8.3.5.pyc differ diff --git a/backend/tests/__pycache__/test_nlp.cpython-312-pytest-8.3.5.pyc b/backend/tests/__pycache__/test_nlp.cpython-312-pytest-8.3.5.pyc new file mode 100644 index 0000000..454d395 Binary files /dev/null and b/backend/tests/__pycache__/test_nlp.cpython-312-pytest-8.3.5.pyc differ diff --git a/backend/tests/__pycache__/test_todo.cpython-312-pytest-8.3.5.pyc b/backend/tests/__pycache__/test_todo.cpython-312-pytest-8.3.5.pyc new file mode 100644 index 0000000..c7a6b6e Binary files /dev/null and b/backend/tests/__pycache__/test_todo.cpython-312-pytest-8.3.5.pyc differ diff --git a/backend/tests/helpers/__pycache__/generators.cpython-312.pyc b/backend/tests/helpers/__pycache__/generators.cpython-312.pyc index 636ef09..71548d4 100644 Binary files a/backend/tests/helpers/__pycache__/generators.cpython-312.pyc and b/backend/tests/helpers/__pycache__/generators.cpython-312.pyc differ diff --git a/backend/tests/helpers/generators.py b/backend/tests/helpers/generators.py index 25c9469..9ed550d 100644 --- a/backend/tests/helpers/generators.py +++ b/backend/tests/helpers/generators.py @@ -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, diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py new file mode 100644 index 0000000..3885fd2 --- /dev/null +++ b/backend/tests/test_admin.py @@ -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 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 10813d2..22c0115 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -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 @@ -177,4 +178,53 @@ def test_delete_user(db: Session, client: TestClient) -> None: # Verify that the user is deleted deleted_user = db.query(User).filter(User.username == user.username).first() assert deleted_user is None - \ No newline at end of file + +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 diff --git a/backend/tests/test_calendar.py b/backend/tests/test_calendar.py index caba7ab..da0eec6 100644 --- a/backend/tests/test_calendar.py +++ b/backend/tests/test_calendar.py @@ -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(), + } + +# --- Test Create Event --- + +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}"}, + json=payload + ) + 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 + + # 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"] + +# --- 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) -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"] - - response = client.post("/api/calendar/events", - json={ - "title": "Test Event", - "start_time": "2024-03-20T15:00:00Z" - }, - headers={"Authorization": f"Bearer {access_token}"}, - cookies={"refresh_token": refresh_token}, + response = client.get( + "/api/calendar/events", + headers={"Authorization": f"Bearer {access_token}"} ) - assert response.status_code == 200 - assert response.json()["title"] == "Test Event" + 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(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"] - - # Create an event to retrieve - client.post("/api/calendar/events", - json={ - "title": "Test Event", - "start_time": "2024-03-20T15:00:00Z" - }, + +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} ) - - response = client.get("/api/calendar/events", + 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"] + + # 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 \ No newline at end of file + 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 + diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py new file mode 100644 index 0000000..2401a93 --- /dev/null +++ b/backend/tests/test_main.py @@ -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"} diff --git a/backend/tests/test_nlp.py b/backend/tests/test_nlp.py new file mode 100644 index 0000000..28f39e3 --- /dev/null +++ b/backend/tests/test_nlp.py @@ -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) diff --git a/backend/tests/test_todo.py b/backend/tests/test_todo.py new file mode 100644 index 0000000..573ea76 --- /dev/null +++ b/backend/tests/test_todo.py @@ -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